From 4e42b6d2cd7af588765c6e5d938c25362ace60ec Mon Sep 17 00:00:00 2001 From: codetalks-hw Date: Fri, 21 Aug 2026 14:13:34 +0800 Subject: [PATCH 001/177] 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: + * + *

+ * + * @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: *

* @@ -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 002/177] 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 003/177] 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 004/177] 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 005/177] 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 006/177] 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 007/177] 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 008/177] 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 009/177] 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 010/177] 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 011/177] 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 012/177] 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 013/177] 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 014/177] 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 015/177] 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 016/177] 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 017/177] 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 018/177] 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 019/177] 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 020/177] 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 021/177] 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 022/177] 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 023/177] 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 024/177] 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 025/177] 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 026/177] 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 027/177] 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 028/177] 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 029/177] 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 030/177] 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 031/177] 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 032/177] 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 033/177] 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 034/177] 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 035/177] 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 036/177] 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 037/177] 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 038/177] 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 039/177] 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 040/177] 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 041/177] 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 042/177] 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 043/177] 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 044/177] 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 045/177] 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 046/177] 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 047/177] 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 048/177] 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 049/177] 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 050/177] 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 051/177] 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 052/177] 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 053/177] 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 054/177] 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 055/177] 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 056/177] 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 057/177] 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 058/177] 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 059/177] 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 060/177] 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 061/177] 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 062/177] 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 063/177] 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 064/177] 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 065/177] 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 066/177] 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 067/177] 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 068/177] 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 069/177] 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 070/177] 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 071/177] 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 072/177] 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 073/177] 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 074/177] 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 075/177] 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 076/177] 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 077/177] 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 078/177] 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 079/177] 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 080/177] 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 081/177] 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 082/177] 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 083/177] 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 084/177] 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 085/177] 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 086/177] 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 087/177] 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 088/177] 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 089/177] 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 090/177] 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 091/177] 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 092/177] 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 093/177] 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 094/177] 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 095/177] 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 096/177] 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 097/177] 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 098/177] 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 099/177] 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 100/177] 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 101/177] 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 102/177] 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 151/177] 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 152/177] 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 153/177] 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 154/177] 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 155/177] 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 156/177] 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 157/177] 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 158/177] 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 159/177] 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 160/177] 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 161/177] 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 162/177] 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 163/177] 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 164/177] 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 165/177] 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 166/177] 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 167/177] 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 168/177] 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 169/177] 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 170/177] 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 171/177] 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 172/177] 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 173/177] 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 174/177] 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 175/177] 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 176/177] 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 177/177] 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;