diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..794ee5cd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,53 @@ +# Auto-detect text files; enable Git's built-in text normalization +* text=auto + +# ===================== +# Java SDK source files +# ===================== +*.java text eol=lf +*.xml text eol=lf +*.properties text eol=lf + +# ===================== +# Build & configuration +# ===================== +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.toml text eol=lf +*.env text eol=lf +*.example text eol=lf + +# ===================== +# Documentation +# ===================== +*.md text eol=lf +*.txt text eol=lf +*.html text eol=lf +*.css text eol=lf +*.js text eol=lf + +# ===================== +# Python tooling +# ===================== +*.py text eol=lf + +# ===================== +# Others +# ===================== +*.lock text eol=lf +*.svg text eol=lf + +# ===================== +# Binary files — never normalize +# ===================== +*.jar binary +*.class binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.zip binary +*.tar binary +*.gz binary \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e6cabb11 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] + +jobs: + build-and-test: + name: Build & Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - name: Set up Python for template lint + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Lint bundled A2A-T templates + run: python tools/template_lint.py --resource-root a2a-t-resources/src/main/resources/prompt_resources + + - name: Build and run tests + # a2a-t-corpus is the accuracy-verification test module: it must run against a real LLM, which CI does not provide, so exclude it. + run: mvn -B clean verify -pl '!a2a-t-corpus' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..19450df6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +name: Release to Maven Central + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +permissions: + contents: read + +jobs: + release: + name: Publish Maven artifacts + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate release secrets + shell: bash + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + GPG_KEY_CONTENTS: ${{ secrets.GPG_KEY_CONTENTS }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + run: | + test -n "${MAVEN_CENTRAL_USERNAME}" || { echo "MAVEN_CENTRAL_USERNAME is not set"; exit 1; } + test -n "${MAVEN_CENTRAL_PASSWORD}" || { echo "MAVEN_CENTRAL_PASSWORD is not set"; exit 1; } + test -n "${GPG_KEY_CONTENTS}" || { echo "GPG_KEY_CONTENTS is not set"; exit 1; } + test -n "${SIGNING_KEY_ID}" || { echo "SIGNING_KEY_ID is not set"; exit 1; } + test -n "${SIGNING_PASSWORD}" || { echo "SIGNING_PASSWORD is not set"; exit 1; } + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + gpg-private-key: ${{ secrets.GPG_KEY_CONTENTS }} + gpg-passphrase: SIGNING_PASSWORD + env: + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + + - name: Configure Maven servers + shell: bash + env: + MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + run: | + mkdir -p ~/.m2 + cat > ~/.m2/settings.xml < + + + central + ${MAVEN_CENTRAL_USERNAME} + ${MAVEN_CENTRAL_PASSWORD} + + + + EOF + + - name: Verify + run: mvn -B verify -pl '!a2a-t-corpus' + + - name: Publish + env: + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.SIGNING_PASSWORD }} + run: > + mvn -B -Prelease deploy -pl '!a2a-t-corpus' + -Dgpg.executable=gpg + -Dgpg.keyname="${SIGNING_KEY_ID}" diff --git a/.gitignore b/.gitignore index 0189904a..b9cd7b3f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,27 @@ target/ *target/ -.gitignore \ No newline at end of file +.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 +!a2a-t-sample/src/main/resources/**/authz.env +reference/ +findings.md +progress.md +task_plan.md + +.idea/ + +.worktrees/ +eval-results/ +eval-run*.log +eval-smoke.json +.jqwik-database +__pycache__/ + +# Accuracy-verification corpus (a2a-t-corpus): transcripts written back into the scenario +# directories by each run are regenerated artifacts, never committed. +a2a-t-corpus/src/test/java/**/suites/*/resources/*/output_result_*.json + +.superpowers/ diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..64e99f94 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,9 @@ +# Code owners for this repository. +# Each line is a file pattern followed by one or more owners. +# The last matching pattern takes precedence. +# Reference: https://docs.github.com/articles/about-code-owners + +# The maintainers team owns all content in this repository, +# including this file and MAINTAINERS.md, and is automatically +# requested for review on every pull request. +* @project-openan/maintainers-a2a-t-sdk diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 00000000..1551cc11 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,26 @@ +# Maintainers + +This file lists the maintainers of this repository. Maintainers have write access and are responsible for reviewing and merging contributions. Changes to this file are made by pull request and reviewed by the existing maintainers. + +| Organization | Name | GitHub Handle | +| ------------ | ---- | ------------- | +| Huawei | Yonglong Wang | @wangyonglong12111 | +| Huawei | Wenjun Zhang | @zhangwenjun-huawei | +| The Linux Foundation | Casey Cain | @wrathwolf | + +## Emeritus Maintainers + +Maintainers who are no longer active but have made significant contributions to this repository. + +| Organization | Name | GitHub Handle | +| ------------ | ---- | ------------- | +| (none yet) | | | + +## Becoming a Maintainer + +Maintainer status is granted by the existing maintainers of this repository, or by the TSC, based on a track record of sustained, quality contributions. To be considered, a contributor should demonstrate: + +- Consistent contributions over time (code, reviews, documentation) +- Understanding of the component's goals and architecture +- Constructive participation in community discussions +- Adherence to the project's Code of Conduct and Contributing guidelines diff --git a/README.md b/README.md index 89025614..ee082e05 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,9 @@ SPDX-License-Identifier: Apache-2.0 Java License

-

Java SDK used to generate task prompts and handle task negotiation flows based on the A2A-T protocol. -
- 基于A2A-T协议用于生成任务提示词并处理任务协商流程的Java SDK。

-

中文

@@ -38,58 +34,127 @@ SPDX-License-Identifier: Apache-2.0 ## Project Overview -`a2a-t-sdk-java` is a Java SDK targeting telecom scenarios, used to generate task prompts and handle task negotiation flows. +A2A-T (Agent-to-Agent Telecom) is a telecom-domain multi-agent interconnection protocol extended from the A2A protocol. It enhances capabilities such as information models, task negotiation, and collaboration security for telecom business scenarios, enabling deterministic, highly reliable, efficient, and secure collaboration among telecom agents. + +`a2a-t-sdk-java` is the Java SDK of the A2A-T protocol. Its core responsibility is to **generate, validate, and negotiate task prompts** (structured protocol messages) in A2A-T interactions. The SDK mainly serves two types of users: -This SDK is primarily aimed at two types of users: +- **Client agent**: converts natural-language or structured input into task prompts that conform to the A2A-T format, and initiates, receives, and advances negotiation flows. +- **Server agent**: validates whether the A2A-T messages sent by the client satisfy the scenario, template, and slot constraints, extracts parameters, and advances negotiation flows. -- Client: Generates task prompts based on user input, and initiates, receives, and advances negotiation flows. -- Server: Validates A2A-T protocol messages that conform to the SDK format, and initiates, receives, and advances negotiation flows. +The A2A-T SDK is independent of the A2A SDK; using the two together builds agents with full A2A-T protocol support (in the Java ecosystem, the matching A2A SDK is a2a-java, coordinates `org.a2aproject.sdk`): + +```mermaid +flowchart LR + subgraph Server["Server Agent"] + B2["A2A SDK (a2a-java)
A2A transport"] --> B1["A2A-T Server SDK
message validation / parameter extraction / negotiation"] + B1 --> B0["Business code"] + end + subgraph Client["Client Agent"] + A0["Business code"] --> A1["A2A-T Client SDK
task prompt / negotiation message generation"] + A1 --> A2["A2A SDK (a2a-java)
A2A transport"] + end + Client -- "HTTP A2A-T request" --> Server + Server -- "HTTP A2A-T response" --> Client +``` ## Core Capabilities -- Task prompt generation pipeline: Covers input normalization, scenario recognition, slot extraction, and task prompt rendering. -- Client API: Provides a task prompt generation result stream, along with negotiation entry points such as `start_negotiation`, `receive_negotiation`, and `continue_negotiation`. -- Server validation API: Targets A2A-T protocol messages that conform to the SDK format, performing metadata parsing, slot extraction. -- Negotiation types: Includes four built-in negotiation types: `information`. -- Resource organization: Built-in prompt resources are located in `a2a-t-resources/src/main/resources/prompt_resources`, containing `prompts`, `scenarios`, `slots`, and `templates`. -- Built-in example scenario: Currently, the package provides scenarios such as`subscribe_incident`. +| Capability | Description | +| --- | --- | +| Task prompt generation (client) | Covers input normalization, scenario recognition, slot extraction, and template rendering; supports both natural-language and structured-data input | +| Message validation and parameter extraction (server) | Performs metadata parsing, slot extraction, and semantic validation on task prompts that conform to the SDK format, extracts parameters per a schema, and returns details of missing/invalid slots | +| Multi-round negotiation & negotiation state management | Supports the three negotiation types `information` / `feasibility` / `target` with template-driven negotiation message generation; covers negotiation types, the runtime state machine, and state storage (currently an in-memory implementation) | +| Resource organization | Built-in prompt resources (`prompts` / `scenarios` / `slots` / `templates`) live in `a2a-t-resources` and support both classpath and local-file loading | +| LLM adaptation | Provides OpenAIClient by default, supports LLMs compatible with the OpenAI specification, and allows custom LLM integration | +| Built-in samples | Ships runnable sample scenarios such as `subscribe_incident` (incident subscription), which run end to end with zero external dependencies | ## Project Structure -The repository is organized as a Maven multi-module reactor, with core code under each module's `src/main/java`. The main modules are: +The repository is organized as Maven multi-modules, with core code under each module's `src/main/java`: + +| Module | Description | +| --- | --- | +| `a2a-t-bom` | Bill of materials (BOM) that aligns library module versions | +| `a2a-t-core` | Shared `.env` configuration loading, value types, JSON parsing abstractions, and exception handling | +| `a2a-t-resources` | Packaging and classpath loading of built-in prompt resources | +| `a2a-t-llm` | LLM adapter layer; supports custom LLM integration and provides OpenAIClient by default | +| `a2a-t-prompt` | Prompt resource model and loading, scenario recognition, slot extraction, and template rendering | +| `a2a-t-negotiation` | Negotiation types, runtime state machine, and state storage | +| `a2a-t-client` | Client facade providing task prompt generation and negotiation entry points (`A2ATClient`) | +| `a2a-t-server` | Server facade providing A2A-T protocol message validation and negotiation entry points (`A2ATServer`) | +| `a2a-t-corpus` | Accuracy verification corpus (pure test module): data-driven workflow cases executed against a real LLM; excluded from packaging and CI | +| `a2a-t-sample` | Runnable client/server samples | + +Each module's `src/test/java` mirrors the main package structure, covering the negotiation state machine, negotiation handlers, resource loaders, prompt rendering, and client/server orchestration. + +## Quick Start + +### Environment Requirements + +| Item | Requirement | +| --- | --- | +| JDK | `>=17` | +| Build tool | Maven (no wrapper is bundled; install it locally) | +| LLM | Optional. Without an API key, samples automatically fall back to the built-in mock LLM and run with zero external dependencies | + +### Run the First Demo in Three Steps + +Taking the `subscribe_incident` (incident subscription) scenario as an example: the client generates a Notification-T task prompt based on natural-language input and sends it to the server over a real HTTP A2A link; the server validates the message, establishes the event subscription, and pushes notifications. Run all commands from the **repository root**: + +```bash +# 1. Build (generates a2a-t-sample/target/sample.args) +mvn -pl a2a-t-sample -am -DskipTests package + +# 2. Terminal 1: start the server (listens on 127.0.0.1:26335 by default) +java @a2a-t-sample/target/sample.args net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain + +# 3. Terminal 2: start the client +java @a2a-t-sample/target/sample.args net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain +``` + +After startup, full-pipeline logs are visible: client-side scenario recognition and slot extraction, the generated task prompt, the A2A request message, and the server-side validation result and subscription notification push. + +> If Chinese characters look garbled in a Windows console, run `chcp 65001` first. + +### Connect a Real LLM (Optional) + +Edit `client/client.env` and `server/server.env` under `a2a-t-sample/src/main/resources/sample/subscribe-incident/` and fill in any OpenAI-compatible endpoint: -- `a2a-t-bom`: Bill of materials (BOM) that aligns library module versions. -- `a2a-t-core`: Shared `.env`-driven configuration loading, value types, JSON parsing abstractions, and the exception processing. -- `a2a-t-resources`: Packaging and classpath loading of built-in prompt resources. -- `a2a-t-llm`: LLM adapter layer, LLMs can be customized and connected. By default, OpenAIClient is provided, and LLMs compatible with OpenAI specifications can be connected. -- `a2a-t-prompt`: Prompt resource model and loading, scenario recognition, slot extraction, and template rendering. -- `a2a-t-negotiation`: Negotiation types, runtime state machine, and state storage. -- `a2a-t-client`: Client facade providing task prompt generation and negotiation entry points. -- `a2a-t-server`: Server facade providing `processed task prompt` validation and negotiation entry points. -- `a2a-t-sample`: Runnable client/server sample. +```properties +# LLM protocol type +A2AT_LLM_PROVIDER=openai +# Model name +A2AT_LLM_MODEL= +# Model endpoint +A2AT_LLM_BASE_URL= +# LLM API key +A2AT_LLM_API_KEY= +``` -## Installation and Environment Requirements +You can also copy the env file and specify it via a startup argument: `java @a2a-t-sample/target/sample.args /path/to/.env`. For a description of all configuration items, see `env.example` at the repository root. -- Java requirement: `>=17` -- Build tool: Maven (no wrapper is bundled; install it locally) -- Group ID: `net.openan.a2a-t.sdk` -- License: `Apache-2.0` -- It is recommended to import `a2a-t-bom` to align module versions: +### Development and Testing -Before starting, edit `client.env` / `server.env` at the repo root (or refer to `env.example`) and fill in a valid `A2AT_LLM_API_KEY` and other settings. +```bash +# Run all tests +mvn test +``` -## Development and Testing +For more runnable samples (end-to-end negotiation, service recovery, authorization policy, private-line complaint, etc.), see [a2a-t-sample/README.zh-CN.md](a2a-t-sample/README.zh-CN.md). -The project uses a Maven multi-module reactor build, with code formatting unified by `spotless-maven-plugin` (Palantir Java Format) and tests based on JUnit 5 (Jupiter). +## More Documents -Each module's `src/test/java` mirrors the main package structure, covering the negotiation state machine, the four negotiation type handlers, classpath and local-file loaders, prompt rendering, and client/server orchestration. For a runnable end-to-end sample, see `a2a-t-sample` and [a2a-t-sample/README.zh-CN.md](a2a-t-sample/README.zh-CN.md). +| Document | Location | Content | +| --- | --- | --- | +| Developer Guide | [docs/en/developer_guide.md](docs/en/developer_guide.md) | Feature introduction, installation and integration, parameter configuration, and minimal practices | +| API Reference | [docs/en/API_Reference.md](docs/en/API_Reference.md) | Full API definitions and usage instructions for `A2ATClient` / `A2ATServer` | ## Current Scope of Support Before use, it is recommended to confirm the following limitations: -- The built-in LLM invocation chain is unified externally as an OpenAI-compatible adaptation layer. -- Prompt resources currently only support local files. +- The built-in LLM invocation chain is unified externally as an OpenAI adaptation layer. +- LLM prompt resources are built in and do not support customization or extension. - Negotiation state storage currently only provides an in-memory implementation and does not guarantee persistence. - The bundled resources and language coverage are limited, and do not include remote resource loading capabilities such as `registry-center`. - This document primarily introduces the SDK itself, and does not cover the CLI, hosted services, deployment processes, or ready-to-use application solutions. diff --git a/README_zh.md b/README_zh.md index 770cec6b..d127ba14 100644 --- a/README_zh.md +++ b/README_zh.md @@ -23,13 +23,9 @@ SPDX-License-Identifier: Apache-2.0 Java License

-

基于A2A-T协议用于生成任务提示词并处理任务协商流程的Java SDK。 -
- Java SDK used to generate task prompts and handle task negotiation flows based on the A2A-T protocol.

-

English

@@ -38,58 +34,127 @@ SPDX-License-Identifier: Apache-2.0 ## 项目简介 -`a2a-t-sdk-java` 是一个面向电信场景的 Java SDK,用于生成任务提示词并处理任务协商流程。 +A2A-T(Agent-to-Agent Telecom)是基于 A2A 协议扩展的电信领域多智能体互联协议,针对电信业务场景的信息模型、任务协商、协作安全等能力进行增强,支撑电信领域多智能体之间确定性、高可靠、高效且安全的协同。 + +`a2a-t-sdk-java` 是 A2A-T 协议的 Java SDK,核心职责是在 A2A-T 交互中**生成、校验与协商任务提示词**(结构化协议报文)。SDK 主要面向两类使用方: -这个 SDK 主要面向两类使用方: +- **客户端 Agent**:将自然语言或结构化输入转化为符合 A2A-T 格式的任务提示词,并发起、接收和推进协商流程。 +- **服务端 Agent**:校验客户端下发的 A2A-T 报文是否满足场景、模板与槽位约束,提取参数并推进协商流程。 -- 客户端:根据用户输入生成任务提示词,并发起、接收和推进协商流程。 -- 服务端:校验符合 SDK 格式的 A2A-T协议报文(处理后的任务提示词),并发起、接收和推进协商流程。 +A2A-T SDK 独立于 A2A SDK,两者配合使用即可构建完整支持 A2A-T 协议的智能体(Java 生态中可搭配的 A2A SDK 为 a2a-java,坐标 `org.a2aproject.sdk`): + +```mermaid +flowchart LR + subgraph Server["服务端 Agent"] + B2["A2A SDK (a2a-java)
A2A 传输"] --> B1["A2A-T Server SDK
报文校验 / 参数提取 / 协商"] + B1 --> B0["业务代码"] + end + subgraph Client["客户端 Agent"] + A0["业务代码"] --> A1["A2A-T Client SDK
生成任务提示词 / 协商报文"] + A1 --> A2["A2A SDK (a2a-java)
A2A 传输"] + end + Client -- "HTTP A2A-T 请求" --> Server + Server -- "HTTP A2A-T 响应" --> Client +``` ## 核心能力 -- 任务提示词生成链路:覆盖输入归一化、场景识别、槽位提取、任务提示词渲染。 -- 客户端 API:提供任务提示词生成结果流,以及 `start_negotiation`、`receive_negotiation`、`continue_negotiation` 等协商入口。 -- 服务端校验 API:面向符合 SDK 格式的 `processed task prompt`,执行元数据解析、槽位提取和槽位校验。 -- 协商类型:内置 `information` 协商类型。 -- 资源组织:内置提示词资源位于 `a2a-t-resources/src/main/resources/prompt_resources`,包含 `prompts`、`scenarios`、`slots`、`templates`。 -- 内置示例场景:当前随包提供 `subscribe_incident`等场景。 +| 能力 | 说明 | +| --- | --- | +| 任务提示词生成(客户端) | 覆盖输入归一化、场景识别、槽位提取与模板渲染,支持自然语言与结构化数据两种输入 | +| 报文校验与提参(服务端) | 对符合 SDK 格式的任务提示词执行元数据解析、槽位提取与语义校验,按 Schema 提取参数并返回缺失/非法槽位明细 | +| 多轮协商&协商状态管理 | 支持 `information` / `feasibility` / `target` 三类协商,提供模板驱动的协商报文生成;协商类型、运行时状态机与状态存储(当前提供内存实现) | +| 资源组织 | 内置提示词资源(`prompts` / `scenarios` / `slots` / `templates`)位于 `a2a-t-resources`,支持 `classpath` 与本地文件两种加载方式 | +| LLM 适配 | 默认提供 OpenAIClient,支持接入兼容 OpenAI 规范的 LLM,并支持自定义 LLM 接入 | +| 内置示例 | 随包提供 `subscribe_incident`(事件订阅)等可运行示例场景,无需外部依赖即可端到端跑通 | ## 项目结构 -仓库采用 Maven 多模块组织,核心代码位于各模块的 `src/main/java`,主要模块如下: +仓库采用 Maven 多模块组织,核心代码位于各模块的 `src/main/java`: + +| 模块 | 说明 | +| --- | --- | +| `a2a-t-bom` | 物料清单(BOM),统一管理各库模块版本 | +| `a2a-t-core` | 共享的 `.env` 配置加载、值类型、JSON 解析抽象与异常处理 | +| `a2a-t-resources` | 内置提示词资源的打包与 classpath 加载 | +| `a2a-t-llm` | LLM 适配层,支持自定义 LLM 接入,默认提供 OpenAIClient | +| `a2a-t-prompt` | 提示词资源模型与加载、场景识别、槽位提取与模板渲染 | +| `a2a-t-negotiation` | 协商类型、运行时状态机与状态存储 | +| `a2a-t-client` | 客户端封装,提供任务提示词生成与协商入口(`A2ATClient`) | +| `a2a-t-server` | 服务端封装,提供 A2A-T 协议报文的校验与协商入口(`A2ATServer`) | +| `a2a-t-corpus` | 准确性验证语料(纯测试模块):数据驱动工作流用例,需配置真实 LLM 执行;不参与打包与 CI | +| `a2a-t-sample` | 可运行的客户端/服务端示例 | + +各模块的 `src/test/java` 与 main 包结构对应,覆盖协商状态机、协商处理器、资源加载器、提示词渲染以及客户端/服务端编排等测试用例。 + +## 快速开始 + +### 环境要求 + +| 项 | 要求 | +| --- | --- | +| JDK | `>=17` | +| 构建工具 | Maven(仓库未内置 wrapper,需本地安装) | +| LLM | 可选。未配置 API Key 时示例自动降级为内置 Mock LLM,零外部依赖即可跑通 | + +### 三步跑通首个 Demo + +以 `subscribe_incident`(事件订阅)场景为例:客户端基于自然语言输入生成 Notification-T 任务提示词,经真实 HTTP A2A 链路发送给服务端;服务端校验报文、建立事件订阅并推送通知。以下命令均在**仓库根目录**执行: + +```bash +# 1. 构建(生成 a2a-t-sample/target/sample.args) +mvn -pl a2a-t-sample -am -DskipTests package + +# 2. 终端一:启动服务端(默认监听 127.0.0.1:26335) +java @a2a-t-sample/target/sample.args net.openan.a2at.sample.subscribe_incident.server.ServerSampleMain + +# 3. 终端二:启动客户端 +java @a2a-t-sample/target/sample.args net.openan.a2at.sample.subscribe_incident.client.ClientSampleMain +``` + +启动后可观察到完整链路日志:客户端场景识别与槽位提取、生成的任务提示词、A2A 请求报文,以及服务端校验结果和订阅通知推送。 + +> Windows 控制台如遇中文乱码,先执行 `chcp 65001`。 + +### 接入真实 LLM(可选) -- `a2a-t-bom`:物料清单(BOM),统一管理各库模块版本。 -- `a2a-t-core`:共享的 `.env` 配置加载、值类型、JSON 解析抽象与异常处理。 -- `a2a-t-resources`:内置提示词资源的打包与 classpath 加载。 -- `a2a-t-llm`:LLM 适配层,支持自定义LLM接入,默认提供OpenAIClient,支持接入兼容OpenAI规范的LLM。 -- `a2a-t-prompt`:提示词资源模型与加载、场景识别、槽位提取与模板渲染。 -- `a2a-t-negotiation`:协商类型、运行时状态机与状态存储。 -- `a2a-t-client`:客户端封装,提供任务提示词生成与协商入口。 -- `a2a-t-server`:服务端封装,提供A2A-T协议报文的校验与协商入口。 -- `a2a-t-sample`:可运行的客户端/服务端示例。 +编辑 `a2a-t-sample/src/main/resources/sample/subscribe-incident/` 下的 `client/client.env` 与 `server/server.env`,填入任一 OpenAI 兼容端点: -## 安装与环境要求 +```properties +# LLM协议类型 +A2AT_LLM_PROVIDER=openai +# 模型名称 +A2AT_LLM_MODEL=<模型名> +# 模型地址 +A2AT_LLM_BASE_URL= +# LLM API KEY +A2AT_LLM_API_KEY=<你的 API Key> +``` -- Java 要求:`>=17` -- 构建工具:Maven(仓库未内置 wrapper,需本地安装) -- Group ID:`net.openan.a2a-t.sdk` -- 许可证:`Apache-2.0` +也可复制 env 文件后通过启动参数指定:`java @a2a-t-sample/target/sample.args /path/to/.env`。全部配置项说明见根目录 `env.example`。 -开始前,建议先编辑仓库根目录的 `client.env` / `server.env`(或参考 `env.example`),补充可用的 `A2AT_LLM_API_KEY` 等配置。 +### 开发与测试 +```bash +# 运行全部测试 +mvn test +``` -## 开发与测试 +更多可运行示例(协商端到端、业务抢通、授权策略、专线投诉等)见 [a2a-t-sample/README.zh-CN.md](a2a-t-sample/README.zh-CN.md)。 -项目使用 Maven 多模块 reactor 构建,代码格式化由 `spotless-maven-plugin`(Palantir Java Format)统一管理,测试基于 JUnit 5(Jupiter)。 +## 更多文档 -各模块的 `src/test/java` 与 main 包结构对应,覆盖协商状态机、四类协商处理器、classpath 与本地文件加载器、提示词渲染以及客户端/服务端编排等测试用例。可运行的端到端示例见 `a2a-t-sample`,详见 [a2a-t-sample/README.zh-CN.md](a2a-t-sample/README.zh-CN.md)。 +| 文档 | 位置 | 内容 | +| --- | --- | --- | +| 开发指南 | [docs/zh/开发指南.md](docs/zh/开发指南.md) | 特性介绍、安装接入、参数配置与最小实践 | +| API 参考 | [docs/zh/API参考.md](docs/zh/API参考.md) | `A2ATClient` / `A2ATServer` 全量 API 定义与使用说明 | ## 当前支持范围 使用前建议先确认以下限制: -- 内置 LLM 调用链对外统一为 OpenAI-compatible 适配层。 -- 提示词资源目前仅支持本地文件。 +- 内置 LLM 调用链对外统一为 OpenAI 适配层。 +- 大模型提示词资源已内置,不支持自定义扩展。 - 协商状态存储目前仅提供内存实现,不保证持久化。 - 随包资源与语言覆盖有限,不包含 `registry-center`(注册中心)等远程资源加载能力。 - 本文档主要介绍 SDK 本身,不涉及 CLI、托管服务、部署流程或可直接使用的应用方案。 diff --git a/a2a-t-bom/pom.xml b/a2a-t-bom/pom.xml index f123ee3c..ccd3d11e 100644 --- a/a2a-t-bom/pom.xml +++ b/a2a-t-bom/pom.xml @@ -51,6 +51,16 @@ a2a-t-server ${project.version} + + net.openan.a2a-t.sdk + a2a-t-observability + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-observability-a2a + ${project.version} + diff --git a/a2a-t-client/pom.xml b/a2a-t-client/pom.xml index 0df4bf7c..8a6abb8a 100644 --- a/a2a-t-client/pom.xml +++ b/a2a-t-client/pom.xml @@ -44,5 +44,11 @@ junit-jupiter test + + ch.qos.logback + logback-classic + 1.4.14 + test + diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index 2ac17868..e75d2767 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -1,51 +1,293 @@ 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 java.util.function.Supplier; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; import net.openan.a2at.sdk.core.model.A2ATConfig; -import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import net.openan.a2at.sdk.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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** - * 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 */ public final class A2ATClient { + private static final Logger LOGGER = LoggerFactory.getLogger(A2ATClient.class); + private final ClientPromptGenerationOrchestrator promptGenerationOrchestrator; private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; + private final NegotiationContentService negotiationContentService; + + private final TemplateQueryService templateQueryService; + + private final TelemetryHook hook; + /** - * Creates a client facade from one user-supplied `.env` path. + * Creates a client facade from one user-supplied {@code .env} path without telemetry observation. * - * @param envPath user-supplied `.env` file path + * @param envPath user-supplied {@code .env} file path */ public A2ATClient(Path envPath) { + this(envPath, null); + } + + /** + * Creates a client facade from one user-supplied {@code .env} path with an optional telemetry hook observing every + * facade operation. + * + * @param envPath user-supplied {@code .env} file path + * @param hook telemetry hook receiving the SDK operation events; {@code null} disables observation + */ + public A2ATClient(Path envPath, @Nullable TelemetryHook hook) { Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); - A2ATConfig config = resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + A2ATConfig config = + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); + this.hook = hook == null ? TelemetryHook.NO_OP : hook; this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); + this.negotiationContentService = + new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.templateQueryService = builder.buildTemplateQueryService(); + } + + private T observed(String method, Supplier action) { + TelemetryOperation started; + try { + started = hook.startSdkOperation("client", method); + } catch (RuntimeException error) { + LOGGER.warn("a2at: startSdkOperation failed; telemetry disabled for this call", error); + started = TelemetryOperation.NO_OP; + } + try (TelemetryOperation operation = started) { + try { + T result = action.get(); + try { + operation.markSuccess(); + } catch (RuntimeException callbackError) { + LOGGER.warn("a2at: telemetry hook callback failed", callbackError); + } + return result; + } catch (RuntimeException error) { + try { + operation.markError(error); + } catch (RuntimeException callbackError) { + LOGGER.warn("a2at: telemetry hook callback failed", callbackError); + } + throw error; + } + } + } + + static @Nullable String terminalOutcome(NegotiationStatus status) { + if (status == NegotiationStatus.AGREED) { + return "accept"; + } + return status == NegotiationStatus.REJECTED ? "reject" : null; + } + + private void reportNegotiationRounds(NegotiationContext context, NegotiationStatus status) { + try { + String outcome = terminalOutcome(status); + if (outcome != null) { + hook.reportNegotiationRounds(context.negotiationId(), outcome, context.round()); + } + } catch (RuntimeException error) { + LOGGER.warn("a2at: negotiation metric report failed", error); + } } /** - * Generates a processed task prompt from raw user input. + * 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 - * @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); + return observed("generateTaskPrompt", () -> promptGenerationOrchestrator.generateTaskPrompt(userInput)); + } + + /** + * Generates a task prompt with metadata from natural-language input using the template identified by the template + * URI, bypassing scenario recognition. + * + * @param text natural-language task input + * @param templateUri template URI string identifying the target template, such as + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull String templateUri) { + return observed("generateTaskPromptFromText", () -> { + Objects.requireNonNull(text, "text"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateTaskPromptFromText(text, parsedTemplateUri); + }); + } + + /** + * Generates a task prompt with metadata from structured input and a data schema using the template identified by + * the template URI, bypassing scenario recognition. + * + * @param data structured task input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI string identifying the target template, such as + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails + */ + public MetadataContent generateTaskPromptFromDataWithSchema( + @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { + return observed("generateTaskPromptFromDataWithSchema", () -> { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, parsedTemplateUri); + }); + } + + /** + * Generates an authorization prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + *

Authorization-T slot schemas are bundled with a2a-t-resources, so the entry point works out of the box with + * classpath resource source. + * + * @param text natural-language authorization input + * @param templateUri template URI string identifying the target template, such as + * {@code Authorization-T/authorization-policy-management/v1} + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull String templateUri) { + return observed("generateAuthPromptFromText", () -> { + Objects.requireNonNull(text, "text"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateAuthPromptFromText(text, parsedTemplateUri); + }); + } + + /** + * Generates an authorization prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + *

Authorization-T slot schemas are bundled with a2a-t-resources, so the entry point works out of the box with + * classpath resource source. + * + * @param data structured authorization input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI string identifying the target template, such as + * {@code Authorization-T/authorization-policy-management/v1} + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails + */ + public MetadataContent generateAuthPromptFromDataWithSchema( + @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { + return observed("generateAuthPromptFromDataWithSchema", () -> { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, parsedTemplateUri); + }); + } + + /** + * Generates a notification prompt with metadata from natural-language input using the template identified by the + * template URI, bypassing scenario recognition. + * + * @param text natural-language notification input + * @param templateUri template URI string identifying the target template, such as + * {@code Notification-T/network-layer/subscribe-incident/v1} + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the text or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull String templateUri) { + return observed("generateNotificationPromptFromText", () -> { + Objects.requireNonNull(text, "text"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateNotificationPromptFromText(text, parsedTemplateUri); + }); + } + + /** + * Generates a notification prompt with metadata from structured input and a data schema using the template + * identified by the template URI, bypassing scenario recognition. + * + * @param data structured notification input as a string-to-object map + * @param schema data schema map describing the meaning of each input field; must not be null or empty + * @param templateUri template URI string identifying the target template, such as + * {@code Notification-T/network-layer/subscribe-incident/v1} + * @return metadata content carrying the resolved template URI, rendered prompt text, and extension URI + * @throws NullPointerException if the template URI, data or schema is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the schema is empty + * @throws net.openan.a2at.sdk.core.exception.PromptGenerationException with the code {@code template.not_found}, + * {@code template.load_failed}, {@code slot.schema_not_found}, {@code llm.invocation_failed}, + * {@code template.render_failed} or {@code slot.not_provided} when generating the prompt fails + */ + public MetadataContent generateNotificationPromptFromDataWithSchema( + @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { + return observed("generateNotificationPromptFromDataWithSchema", () -> { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema( + data, schema, parsedTemplateUri); + }); } /** @@ -57,7 +299,7 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { * @return transport payload representing the initial negotiation turn */ public Map startNegotiation(NegotiationType type, String contentText, Map facts) { - return negotiationOrchestrator.startNegotiation(type, contentText, facts); + return observed("startNegotiation", () -> negotiationOrchestrator.startNegotiation(type, contentText, facts)); } /** @@ -68,7 +310,7 @@ public Map startNegotiation(NegotiationType type, String content * @return normalized payload describing the receive result */ public Map receiveNegotiation(String message, Map context) { - return negotiationOrchestrator.receiveNegotiation(message, context); + return observed("receiveNegotiation", () -> negotiationOrchestrator.receiveNegotiation(message, context)); } /** @@ -81,20 +323,452 @@ public Map receiveNegotiation(String message, Map continueNegotiation( NegotiationContext context, NegotiationStatus status, String contentText) { - return negotiationOrchestrator.continueNegotiation(context, status, contentText); - } - - private A2ATConfig resolvePromptResourceLocalRootDir(A2ATConfig config, Path envPath) { - String localRootDir = config.prompt().localRootDir(); - Path localRootPath = Path.of(localRootDir); - Path resolvedLocalRootPath = localRootPath.isAbsolute() - ? localRootPath.normalize() - : envPath.getParent().resolve(localRootPath).toAbsolutePath().normalize(); - return new A2ATConfig( - new PromptRuntimeConfig( - config.prompt().language(), config.prompt().sourceType(), resolvedLocalRootPath.toString()), - config.llm(), - config.negotiation(), - config.promptCompliance()); + return observed("continueNegotiation", () -> { + Map result = negotiationOrchestrator.continueNegotiation(context, status, contentText); + reportNegotiationRounds(context, status); + return result; + }); + } + + /** + * Generates a propose-phase negotiation message from typed data. + * + *

This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the + * generator of the negotiation type addressed by the template URI and rendered from that template. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/propose/v1}; its + * performative segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content type + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, the code + * {@code negotiation.invalid_input} when the content combines a non-blank confirm request with conditional + * sections, or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateNegotiationProposePromptFromData( + @NonNull NegotiationProposeData data, @NonNull String templateUri) { + return observed("generateNegotiationProposePromptFromData", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateProposeFromData(data, parsedTemplateUri); + }); + } + + /** + * Generates an accept-phase negotiation message from typed data. + * + *

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

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

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

This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI string such as {@code Negotiation-T/target-negotiation/propose/v1}; its performative + * segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the + * phase, or the code {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateNegotiationProposePromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + return observed("generateNegotiationProposePromptFromText", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateProposeFromText(text, context, parsedTemplateUri); + }); + } + + /** + * Generates an accept-phase negotiation message from free text. + * + *

This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Accept}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; + * its performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} + * when the extracted conclusion is not {@code Accept}, or the code {@code input.text_too_long} when the text + * exceeds the configured maximum length + */ + public MetadataContent generateNegotiationAcceptPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + return observed("generateNegotiationAcceptPromptFromText", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAcceptFromText(text, context, parsedTemplateUri); + }); + } + + /** + * Generates a reject-phase negotiation message from free text. + * + *

This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Reject}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; + * its performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses a required + * field, or {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} + * when the extracted conclusion is not {@code Reject}, or the code {@code input.text_too_long} when the text + * exceeds the configured maximum length + */ + public MetadataContent generateNegotiationRejectPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + return observed("generateNegotiationRejectPromptFromText", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateRejectFromText(text, context, parsedTemplateUri); + }); + } + + /** + * Generates an abort negotiation message from free text. + * + *

This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. + * + * @param text free-text input stating the termination reason + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI string of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails + * after exhausting its retries, {@code negotiation.field_missing} when the extracted content misses the + * termination reason, or {@code negotiation.invalid_input} when the text is blank, or the code + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateNegotiationAbortPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + return observed("generateNegotiationAbortPromptFromText", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAbortFromText(text, context, parsedTemplateUri); + }); + } + + /** + * Lists every template available for the configured language across all A2A-T extensions. + * + *

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

This query never throws for a well-formed template URI: a missing template yields an empty result together + * with a warning log instead of a failure. + * + * @param templateUri template URI string such as {@code Negotiation-T/target-negotiation/propose/v1} or + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return the addressed template, or an empty optional when no template exists for it in the configured language + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + */ + public Optional getPrompt(@NonNull String templateUri) { + return observed("getPrompt", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return templateQueryService.getPrompt(parsedTemplateUri); + }); + } + + /** + * Validates a propose-phase negotiation message and extracts its parameters. + * + *

The pipeline checks the template URI before any LLM call, runs the deterministic rule gate on the negotiation + * context carried in the {@code negotiationContext} metadata entry, then performs one LLM semantic validation call + * that also extracts the parameters, and finally merges the parameters with the context parameters taking + * precedence. The semantic step is retried up to the configured attempt limit on the retryable LLM infrastructure + * failure code. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI string declaring the expected negotiation type and phase; its performative segment must + * be {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the template URI phase contradicts + * the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateProposePromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + return observed("validateProposePromptAndDataFilling", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateProposePromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); + }); + } + + /** + * Validates an accept-phase negotiation message and extracts its parameters. + * + *

The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * accept-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI string declaring the expected negotiation type and phase; its performative segment must + * be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the template URI phase contradicts + * the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateAcceptPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + return observed("validateAcceptPromptAndDataFilling", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateAcceptPromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); + }); + } + + /** + * Validates a reject-phase negotiation message and extracts its parameters. + * + *

The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * reject-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI string declaring the expected negotiation type and phase; its performative segment must + * be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or the template URI phase contradicts + * the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateRejectPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + return observed("validateRejectPromptAndDataFilling", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateRejectPromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); + }); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * abort: the template URI must address the common abort template and the message must satisfy the abort-phase + * semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI string of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateAbortPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + return observed("validateAbortPromptAndDataFilling", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateAbortPromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); + }); + } + + private static TemplateUri parseTemplateUri(String templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); } } diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/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/assembly/ClientPromptGenerationOrchestratorBuilder.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptGenerationOrchestratorBuilder.java index 2abf1a86..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 @@ -1,20 +1,19 @@ 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.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; +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; +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.api.TaskPromptRenderer; +import net.openan.a2at.sdk.prompt.taskrendering.TaskPromptRenderer; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; /** @@ -22,9 +21,10 @@ * * @since 2026-06 */ -@NoArgsConstructor(access = AccessLevel.PRIVATE) public final class ClientPromptGenerationOrchestratorBuilder { + private ClientPromptGenerationOrchestratorBuilder() {} + private LLMClient llmClient; private List scenarios; @@ -39,16 +39,18 @@ public final class ClientPromptGenerationOrchestratorBuilder { private String slotUserPrompt; - private ClientScenarioRecognizer scenarioRecognizer; + private ScenarioRecognizer scenarioRecognizer; - private ClientTemplateLoader templateLoader; + private PromptTemplateTextLoader templateLoader; - private ClientSlotSchemaLoader slotSchemaLoader; + private PromptSlotSchemaLoader slotSchemaLoader; - private ClientSlotValueExtractor slotValueExtractor; + private PromptSlotValueExtractor slotValueExtractor; private TaskPromptRenderer renderer; + private Integer maxTextChars; + private ClasspathPromptResourceLoader resourceLoader; /** @@ -143,7 +145,8 @@ public ClientPromptGenerationOrchestratorBuilder slotUserPrompt(String slotUserP * @param scenarioRecognizer scenario recognizer implementation * @return current builder */ - public ClientPromptGenerationOrchestratorBuilder scenarioRecognizer(ClientScenarioRecognizer scenarioRecognizer) { + public ClientPromptGenerationOrchestratorBuilder scenarioRecognizer( + ScenarioRecognizer scenarioRecognizer) { this.scenarioRecognizer = scenarioRecognizer; return this; } @@ -154,7 +157,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 +168,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 +179,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; } @@ -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. * @@ -221,18 +236,17 @@ 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 = - 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( @@ -243,7 +257,9 @@ public DefaultClientPromptGenerationOrchestrator build() { scenarioUserPrompt, effectiveTemplateLoader, effectiveSlotValueExtractor, - effectiveRenderer); + effectiveRenderer, + 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 e9f0a6f4..0206bbfb 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilder.java @@ -2,33 +2,29 @@ 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.orchestration.DefaultClientPromptGenerationOrchestrator; -import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMClientFactory; -import net.openan.a2at.sdk.llm.LLMConfigLoader; +import net.openan.a2at.sdk.negotiation.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.api.TaskPromptRenderer; +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. @@ -37,10 +33,6 @@ */ public final class DefaultA2ATClientBuilder { - private static final String LOCAL_RULE_PROVIDER = "local_rule"; - - private static final String OPENAI_COMPATIBLE_PROVIDER = "openai_compatible"; - private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; @@ -49,6 +41,8 @@ public final class DefaultA2ATClientBuilder { private Path envPath; + private LLMClient llmClient; + /** * Creates one new builder instance. * @@ -80,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. * @@ -93,59 +103,37 @@ public ClientPromptGenerationOrchestrator buildPromptGenerationOrchestrator() { PromptResourceAccess resources = PromptResourceAccess.create(config.prompt()); List scenarios = resources.loadScenarios(config.prompt().language()); - if (LOCAL_RULE_PROVIDER.equals(config.llm().provider())) { - return buildLocalRulePromptGenerationOrchestrator(resources, scenarios); - } - return buildLlmBackedPromptGenerationOrchestrator(resources, scenarios); - } + PromptSlotSchemaLoader slotSchemaLoader = resources.slotSchemaLoader(); + PromptTemplateTextLoader templateLoader = resources.templateLoader(); + String language = config.prompt().language(); - private ClientPromptGenerationOrchestrator buildLocalRulePromptGenerationOrchestrator( - PromptResourceAccess resources, List scenarios) { - ClientSlotSchemaLoader slotSchemaLoader = - newClientSlotSchemaLoader(resources, config.prompt().sourceType()); - return new DefaultClientPromptGenerationOrchestrator( - new FirstScenarioRecognizer(slotSchemaLoader, config.prompt().language()), - scenarios, - config.prompt().language(), - "", - "", - newClientTemplateLoader(resources, config.prompt().sourceType()), - new DefaultTemplateDrivenSlotValueExtractor(slotSchemaLoader), - new TaskPromptRenderer()); - } + LLMClient client = llmClient(); - private ClientPromptGenerationOrchestrator buildLlmBackedPromptGenerationOrchestrator( - PromptResourceAccess resources, List scenarios) { - ClientSlotSchemaLoader slotSchemaLoader = - newClientSlotSchemaLoader(resources, config.prompt().sourceType()); - ClientTemplateLoader templateLoader = - newClientTemplateLoader(resources, config.prompt().sourceType()); - LLMClient llmClient = createLlmClient(); - String language = config.prompt().language(); String scenarioSystemPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "system.md"); String scenarioUserPrompt = resources.loadPrompt(SCENARIO_RECOGNITION_PROMPT, language, "user.md"); String slotSystemPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "system.md"); String slotUserPrompt = resources.loadPrompt(SLOT_EXTRACTION_PROMPT, language, "user.md"); - ClientScenarioRecognizer llmScenarioRecognizer = - new SingleScenarioAwareRecognizer(scenarios, new ScenarioRecognizer(llmClient)::recognize); - ClientSlotValueExtractor slotValueExtractor = new StructuredInputAwareSlotValueExtractor( - new DefaultTemplateDrivenSlotValueExtractor(slotSchemaLoader), - new DefaultStructuredClientSlotValueExtractor( - llmClient, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)); + + ScenarioRecognizer scenarioRecognizer = + new SingleScenarioAwareRecognizer(scenarios, new LlmScenarioRecognizer(client)::recognize); + PromptSlotValueExtractor slotValueExtractor = new DefaultStructuredPromptSlotValueExtractor( + client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt); + return ClientPromptGenerationOrchestratorBuilder.builder() - .llmClient(llmClient) + .llmClient(client) .scenarios(scenarios) .language(language) .scenarioSystemPrompt(scenarioSystemPrompt) .scenarioUserPrompt(scenarioUserPrompt) .slotSystemPrompt(slotSystemPrompt) .slotUserPrompt(slotUserPrompt) - .scenarioRecognizer(llmScenarioRecognizer) + .scenarioRecognizer(scenarioRecognizer) .templateLoader(templateLoader) .slotSchemaLoader(slotSchemaLoader) .slotValueExtractor(slotValueExtractor) .renderer(new TaskPromptRenderer()) + .maxTextChars(config.inputLimits().maxTextChars()) .build(); } @@ -164,6 +152,39 @@ public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { 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( @@ -171,8 +192,7 @@ private void requireSupportedConfig() { throw new UnsupportedOperationException( "Unsupported prompt source type: " + config.prompt().sourceType()); } - if (!LOCAL_RULE_PROVIDER.equals(config.llm().provider()) - && !OPENAI_COMPATIBLE_PROVIDER.equals(config.llm().provider())) { + if (!LLMClientFactory.availableProviders().contains(config.llm().provider())) { throw new UnsupportedOperationException( "Unsupported LLM provider: " + config.llm().provider()); } @@ -182,9 +202,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) { @@ -193,102 +215,24 @@ 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 ScenarioRecognizer { - 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 static final class FirstScenarioRecognizer - implements net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer { - - private final ClientSlotSchemaLoader slotSchemaLoader; + private final List scenarios; - private final String language; + private final ScenarioRecognizer delegate; - private FirstScenarioRecognizer(ClientSlotSchemaLoader slotSchemaLoader, String language) { - this.slotSchemaLoader = slotSchemaLoader; - this.language = language; + 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 (scenarios.isEmpty()) { - return new ScenarioRecognitionResult(false, null, "No scenarios configured."); - } - - Map normalizedFacts = normalizeInput(normalizedInput); - ScenarioDefinition bestScenario = scenarios.get(0); - int bestScore = Integer.MIN_VALUE; - for (ScenarioDefinition scenario : scenarios) { - int score = scoreScenario(scenario, normalizedFacts); - if (score > bestScore) { - bestScenario = scenario; - bestScore = score; - } - } - return new ScenarioRecognitionResult(true, bestScenario.scenarioCode(), null); - } - - private int scoreScenario(ScenarioDefinition scenario, Map normalizedFacts) { - int score = 0; - for (var slotDefinition : slotSchemaLoader - .loadSlotSchema(scenario.scenarioCode(), language) - .slotDefinitions()) { - if (normalizedFacts.containsKey(slotDefinition.name())) { - score += slotDefinition.required() ? 10 : 3; - } - } - return score; - } - - private static Map normalizeInput(String normalizedInput) { - return Map.of("input", normalizedInput); - } - } - - private record SingleScenarioAwareRecognizer(List scenarios, ClientScenarioRecognizer delegate) - implements ClientScenarioRecognizer { - - @Override - public ScenarioRecognitionResult recognize( - String normalizedInput, - List ignoredScenarios, - String systemPrompt, - String userPrompt) { - if (scenarios.size() == 1) { - return new ScenarioRecognitionResult(true, scenarios.get(0).scenarioCode(), null); - } - return delegate.recognize(normalizedInput, scenarios, systemPrompt, userPrompt); - } - } - - private record StructuredInputAwareSlotValueExtractor( - ClientSlotValueExtractor structuredExtractor, ClientSlotValueExtractor llmExtractor) - implements ClientSlotValueExtractor { - - @Override - public Map extractSlots( - Object userInput, String scenarioCode, String language, String templateText) { - if (userInput instanceof Map) { - return structuredExtractor.extractSlots(userInput, scenarioCode, language, templateText); + if (this.scenarios.size() == 1) { + return new ScenarioRecognitionResult(true, this.scenarios.get(0).scenarioCode(), null); } - return llmExtractor.extractSlots(userInput, scenarioCode, language, templateText); + return delegate.recognize(normalizedInput, this.scenarios, systemPrompt, userPrompt); } } } 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 93dd4d20..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/ClientSlotValueExtractor.java +++ /dev/null @@ -1,23 +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); -} 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 ed910153..00000000 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractor.java +++ /dev/null @@ -1,42 +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()); - } -} 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/ClientPromptGenerationOrchestrator.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java index 4f317beb..21e59af5 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/ClientPromptGenerationOrchestrator.java @@ -1,6 +1,9 @@ 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. @@ -12,8 +15,90 @@ 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); + + /** + * 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 08fb571f..eb7fbc10 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestrator.java @@ -1,17 +1,42 @@ package net.openan.a2at.sdk.client.prompt.orchestration; +import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; import net.openan.a2at.sdk.client.model.PromptGenerationFailure; import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import net.openan.a2at.sdk.client.prompt.extractor.ClientSlotValueExtractor; -import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; -import net.openan.a2at.sdk.client.prompt.recognition.ClientScenarioRecognizer; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMConfigError; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.prompt.analysis.exception.ScenarioRecognitionException; +import net.openan.a2at.sdk.prompt.analysis.impl.PromptSlotValueExtractor; +import net.openan.a2at.sdk.prompt.analysis.impl.ScenarioRecognizer; import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotExtractionResult; +import net.openan.a2at.sdk.prompt.analysis.model.StructuredSlotValidationError; +import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; +import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotDefinition; +import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; +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. @@ -20,7 +45,24 @@ */ public final class DefaultClientPromptGenerationOrchestrator implements ClientPromptGenerationOrchestrator { - private final ClientScenarioRecognizer scenarioRecognizer; + 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; @@ -30,13 +72,15 @@ public final class DefaultClientPromptGenerationOrchestrator implements ClientPr private final String userPrompt; - private final ClientTemplateLoader templateLoader; + private final PromptTemplateTextLoader templateLoader; + + private final PromptSlotValueExtractor slotValueExtractor; - private final ClientSlotValueExtractor slotValueExtractor; + private final PromptSlotSchemaLoader slotSchemaLoader; private final TaskPromptRenderer renderer; - private String lastNormalizedInput; + private final int maxTextChars; /** * Creates a client prompt-generation orchestrator with explicit collaborators. @@ -49,16 +93,56 @@ public final class DefaultClientPromptGenerationOrchestrator implements ClientPr * @param templateLoader template loader * @param slotValueExtractor slot value extractor * @param renderer task prompt renderer + * @param slotSchemaLoader slot schema loader + */ + public DefaultClientPromptGenerationOrchestrator( + ScenarioRecognizer scenarioRecognizer, + List scenarios, + String language, + String systemPrompt, + String userPrompt, + PromptTemplateTextLoader templateLoader, + PromptSlotValueExtractor slotValueExtractor, + TaskPromptRenderer renderer, + PromptSlotSchemaLoader slotSchemaLoader) { + this( + scenarioRecognizer, + scenarios, + language, + systemPrompt, + userPrompt, + templateLoader, + slotValueExtractor, + renderer, + slotSchemaLoader, + InputLimitConfig.DEFAULT_MAX_TEXT_CHARS); + } + + /** + * Creates a client prompt-generation orchestrator with explicit collaborators and one free-text input length limit. + * + * @param scenarioRecognizer scenario recognizer + * @param scenarios supported scenario definitions + * @param language locale identifier for resource lookup + * @param systemPrompt system prompt for scenario recognition + * @param userPrompt user prompt for scenario recognition + * @param templateLoader template loader + * @param slotValueExtractor slot value extractor + * @param renderer task prompt renderer + * @param slotSchemaLoader slot schema loader + * @param maxTextChars maximum accepted length in characters for free-text inputs */ public DefaultClientPromptGenerationOrchestrator( - ClientScenarioRecognizer scenarioRecognizer, + ScenarioRecognizer scenarioRecognizer, List scenarios, String language, String systemPrompt, String userPrompt, - ClientTemplateLoader templateLoader, - ClientSlotValueExtractor slotValueExtractor, - TaskPromptRenderer renderer) { + PromptTemplateTextLoader templateLoader, + PromptSlotValueExtractor slotValueExtractor, + TaskPromptRenderer renderer, + PromptSlotSchemaLoader slotSchemaLoader, + int maxTextChars) { this.scenarioRecognizer = scenarioRecognizer; this.scenarios = scenarios; this.language = language; @@ -67,49 +151,380 @@ public DefaultClientPromptGenerationOrchestrator( this.templateLoader = templateLoader; this.slotValueExtractor = slotValueExtractor; this.renderer = renderer; + this.slotSchemaLoader = slotSchemaLoader; + this.maxTextChars = maxTextChars; } @Override public PromptGenerationResult generateTaskPrompt(Object userInput) { + if (userInput instanceof String text && InputLimitConfig.isTooLong(text, maxTextChars)) { + return failure( + ErrorCatalog.INPUT_TEXT_TOO_LONG, + Map.of("actual_length", String.valueOf(text.length()), "max_chars", String.valueOf(maxTextChars)), + "input"); + } String normalizedInput = String.valueOf(userInput); - this.lastNormalizedInput = normalizedInput; final ScenarioRecognitionResult recognition; try { recognition = scenarioRecognizer.recognize(normalizedInput, scenarios, systemPrompt, userPrompt); } catch (ResourceNotFoundException error) { - return PromptGenerationResult.failure( - new PromptGenerationFailure("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("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 { - Map slots = - slotValueExtractor.extractSlots(userInput, recognition.scenarioCode(), language, templateText); + 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("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"); + } + } + + @Override + public MetadataContent generateTaskPromptFromText(String text, TemplateUri templateUri) { + return generateFromTemplateUriWithMetadata(text, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) { + return generateFromDataWithSchema(data, schema, templateUri, ExtensionUriConstants.TASK_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateAuthPromptFromText(String text, TemplateUri templateUri) { + return generateFromTemplateUriWithMetadata( + text, templateUri, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateAuthPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) { + return generateFromDataWithSchema( + data, schema, templateUri, ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateNotificationPromptFromText(String text, TemplateUri templateUri) { + return generateFromTemplateUriWithMetadata( + text, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); + } + + @Override + public MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri) { + return generateFromDataWithSchema( + data, schema, templateUri, ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); + } + + private MetadataContent generateFromTemplateUriWithMetadata( + String userInput, TemplateUri templateUri, String extensionUri) { + Objects.requireNonNull(userInput, "userInput"); + if (InputLimitConfig.isTooLong(userInput, maxTextChars)) { + throw new PromptGenerationException( + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), + render( + ErrorCatalog.INPUT_TEXT_TOO_LONG, + Map.of( + "actual_length", + String.valueOf(userInput.length()), + "max_chars", + String.valueOf(maxTextChars)))); + } + return generateWithMetadata( + templateUri, + extensionUri, + templateIdentifier -> slotValueExtractor.extractSlots(userInput, templateIdentifier, language)); + } + + private MetadataContent generateFromDataWithSchema( + Map data, Map schema, TemplateUri templateUri, String extensionUri) { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + if (schema.isEmpty()) { + throw new IllegalArgumentException( + "Data schema must not be empty; it describes the meaning of each input field."); } + return generateWithMetadata( + templateUri, + extensionUri, + templateIdentifier -> slotValueExtractor.extractSlots(data, templateIdentifier, language, schema)); + } + + private MetadataContent generateWithMetadata( + TemplateUri templateUri, + String extensionUri, + Function slotExtractor) { + Objects.requireNonNull(templateUri, "templateUri"); + String templateIdentifier = templateUri.uri(); + final String templateText; + try { + templateText = templateLoader.loadTemplate(templateIdentifier, language); + } catch (ResourceNotFoundException e) { + throw new PromptGenerationException( + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + render( + ErrorCatalog.TEMPLATE_NOT_FOUND, + Map.of("template_uri", templateIdentifier, "language", language)), + e); + } catch (A2ATError e) { + throw new PromptGenerationException( + ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode(), + render(ErrorCatalog.TEMPLATE_LOAD_FAILED, Map.of("resource_path", templateIdentifier)), + e); + } + final StructuredSlotExtractionResult extractionResult; + try { + extractionResult = slotExtractor.apply(templateIdentifier); + } catch (ResourceNotFoundException e) { + throw new PromptGenerationException( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), + render( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, + Map.of("template_uri", templateIdentifier, "language", language)), + e); + } catch (LLMConfigError e) { + throw new PromptGenerationException( + ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), render(ErrorCatalog.LLM_NOT_CONFIGURED, Map.of()), e); + } catch (LLMError e) { + throw llmException(e, STEP_SLOT_EXTRACTION); + } catch (A2ATError e) { + throw new PromptGenerationException( + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + render(ErrorCatalog.LLM_INVOCATION_FAILED, Map.of("reason", e.getMessage())), + e); + } + List extractionErrors = mapSlotErrors(extractionResult.slotErrors()); + if (!extractionErrors.isEmpty()) { + throw new PromptGenerationException( + extractionErrors.get(0).code(), joinMessages(extractionErrors), extractionErrors); + } + Map slots = extractionResult.slots(); + validateRequiredSlots(slots, templateIdentifier); + final String renderedPrompt; + try { + renderedPrompt = renderer.render(templateText, slots); + } catch (TaskPromptRenderException e) { + throw new PromptGenerationException( + ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode(), + render( + ErrorCatalog.TEMPLATE_RENDER_FAILED, + Map.of("template_uri", templateIdentifier, "reason", e.getMessage())), + e); + } + return new MetadataContent(templateIdentifier, renderedPrompt, extensionUri); + } + + private void validateRequiredSlots(Map slots, String templateIdentifier) { + final PromptSlotSchema schema; + try { + schema = slotSchemaLoader.loadSlotSchema(templateIdentifier, language); + } catch (A2ATError e) { + throw new PromptGenerationException( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), + render( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, + Map.of("template_uri", templateIdentifier, "language", language)), + e); + } + if (schema == null) { + throw new PromptGenerationException( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), + render( + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND, + Map.of("template_uri", templateIdentifier, "language", language))); + } + List defs = schema.slotDefinitions(); + if (defs == null) { + return; + } + Map effectiveSlots = slots; + if (effectiveSlots == null) { + effectiveSlots = Map.of(); + } + List failed = new ArrayList<>(); + for (PromptSlotDefinition def : defs) { + if (def == null) { + continue; + } + if (def.required()) { + String name = def.name(); + if (name == null) { + continue; + } + String value = effectiveSlots.get(name); + if (value == null || value.trim().isEmpty()) { + failed.add(notProvidedError(name, slotLabel(def))); + } + } + } + if (!failed.isEmpty()) { + throw new PromptGenerationException(failed.get(0).code(), joinMessages(failed), failed); + } + } + + /** + * Maps the extraction-time slot errors reported by the slot-extraction step to catalog errors. + * + *

Unknown codes returned by the LLM step are mapped to the {@code slot.rule_violation} fallback and logged. + * + * @param slotErrors extraction-time slot errors, may be null + * @return mapped slot validation errors, possibly empty, never null + */ + private List mapSlotErrors(@Nullable List slotErrors) { + if (slotErrors == null || slotErrors.isEmpty()) { + return List.of(); + } + List mapped = new ArrayList<>(); + for (StructuredSlotValidationError error : slotErrors) { + if (error == null) { + continue; + } + mapped.add(mapSlotError(error)); + } + return mapped; + } + + private SlotValidationError mapSlotError(StructuredSlotValidationError error) { + String label = error.slotName() == null ? "" : error.slotName(); + ErrorCatalog entry; + if (ErrorCatalog.SLOT_NOT_PROVIDED.getCode().equals(error.code()) + || LEGACY_CODE_MISSING_INPUT.equals(error.code())) { + entry = ErrorCatalog.SLOT_NOT_PROVIDED; + } else if (ErrorCatalog.SLOT_CONSTRAINT_VIOLATED.getCode().equals(error.code()) + || LEGACY_CODE_INVALID_VALUE.equals(error.code())) { + entry = ErrorCatalog.SLOT_CONSTRAINT_VIOLATED; + } else { + LOGGER.warn( + "Unknown slot-extraction error code '{}' mapped to '{}'.", + error.code(), + ErrorCatalog.SLOT_RULE_VIOLATION.getCode()); + entry = ErrorCatalog.SLOT_RULE_VIOLATION; + } + Map facts = Map.of("slot_label", label); + return new SlotValidationError(label, entry.getCode(), render(entry, facts), facts); + } + + private SlotValidationError notProvidedError(String slotName, String slotLabel) { + Map facts = Map.of("slot_label", slotLabel); + return new SlotValidationError( + slotName, + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + render(ErrorCatalog.SLOT_NOT_PROVIDED, facts), + facts); + } + + private String slotLabel(PromptSlotDefinition definition) { + if (definition.description() != null && !definition.description().isBlank()) { + return definition.description(); + } + return definition.name() == null ? "" : definition.name(); + } + + private @Nullable PromptGenerationFailure slotExtractionFailure( + @Nullable List slotErrors) { + List mapped = mapSlotErrors(slotErrors); + if (mapped.isEmpty()) { + return null; + } + return new PromptGenerationFailure(mapped.get(0).code(), joinMessages(mapped), "generation"); + } + + private PromptGenerationFailure llmFailure(LLMError error, String step) { + if (error instanceof LLMConfigError) { + return new PromptGenerationFailure( + ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), + render(ErrorCatalog.LLM_NOT_CONFIGURED, Map.of()), + "generation"); + } + if (isResponseContractViolation(error)) { + return new PromptGenerationFailure( + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + render(ErrorCatalog.LLM_RESPONSE_INVALID, Map.of("step", step)), + "generation"); + } + return new PromptGenerationFailure( + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + render(ErrorCatalog.LLM_INVOCATION_FAILED, Map.of("reason", error.getMessage())), + "generation"); + } + + private PromptGenerationException llmException(LLMError error, String step) { + PromptGenerationFailure failure = llmFailure(error, step); + return new PromptGenerationException(failure.code(), failure.message(), error); + } + + private boolean isResponseContractViolation(LLMError error) { + String message = error.getMessage() == null ? "" : error.getMessage().toLowerCase(Locale.ROOT); + for (String marker : RESPONSE_CONTRACT_MARKERS) { + if (message.contains(marker)) { + return true; + } + } + return false; + } + + private String joinMessages(List errors) { + return errors.stream().map(SlotValidationError::message).collect(Collectors.joining("; ")); + } + + private String render(ErrorCatalog entry, Map facts) { + return ErrorMessages.render(entry, language, facts); } - String lastNormalizedInput() { - return lastNormalizedInput; + 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/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/A2ATClientNegotiationApiTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java new file mode 100644 index 00000000..4a0d8a1c --- /dev/null +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationApiTest.java @@ -0,0 +1,219 @@ +package net.openan.a2at.sdk.client.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class A2ATClientNegotiationApiTest { + + private static final String TEST_MOCK_PROVIDER = "test-negotiation-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + @Test + void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); + Map metadata = result.buildMetadataContent(); + assertEquals(3, metadata.size()); + assertEquals(result.promptText(), metadata.get(result.extensionUri())); + assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); + } + + @Test + void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("en-US")); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE_URI); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); + assertTrue(result.promptText().contains("Required Information Items")); + } + + @Test + void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { + assertEquals(7, negotiationPrompts(new A2ATClient(writeEnv("zh-CN"))).size()); + assertEquals(7, negotiationPrompts(new A2ATClient(writeEnv("en-US"))).size()); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + MetadataContent result = client.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 5, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + StandardTemplates.NEGOTIATION_ABORT_URI); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("## 协商终止原因")); + assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); + assertEquals(5, result.negotiationContext().round()); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinEnglishTemplates() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("en-US")); + + MetadataContent result = client.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 3, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent( + "Reached the negotiation round limit. This negotiation is confirmed and ended.")), + StandardTemplates.NEGOTIATION_ABORT_URI); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## Negotiation Result\nAbort")); + assertTrue(result.promptText().contains("## Negotiation Termination Reason")); + assertTrue(result.promptText().contains("Reached the negotiation round limit.")); + } + + @Test + void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + PromptTemplate template = + client.getPrompt(StandardTemplates.NEGOTIATION_ABORT_URI).orElseThrow(); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + @Test + void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + assertTrue(client.getPrompt(INFORMATION_PROPOSE_URI).isPresent()); + assertTrue(client.getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI) + .isPresent()); + assertFalse(client.getPrompt("Negotiation-T/information-negotiation/propose/v9").isPresent()); + PromptTemplate template = client.getPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); + assertEquals(INFORMATION_PROPOSE, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + @Test + void generateNegotiationPromptFromDataThrowsOnMalformedTemplateUriString() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + "Task-T/only-one-segment")); + assertTrue(ex.getMessage().contains("Unparseable template URI"), "message was: " + ex.getMessage()); + } + + @Test + void validateNegotiationPromptThrowsOnBlankTemplateUriString() throws IOException { + A2ATClient client = new A2ATClient(writeEnv("zh-CN")); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> client.validateProposePromptAndDataFilling( + "rendered negotiation message", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + " ")); + assertTrue(ex.getMessage().contains("Unparseable template URI"), "message was: " + ex.getMessage()); + } + + private static List negotiationPrompts(A2ATClient client) { + return client.getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + + private static Path writeEnv(String language) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-negotiation-env"); + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=%s + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-negotiation-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(language)); + return envFile; + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} 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 new file mode 100644 index 00000000..2a83e7b6 --- /dev/null +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientNegotiationEnvConfigTest.java @@ -0,0 +1,240 @@ +package net.openan.a2at.sdk.client.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Drives the env-configured negotiation behavior of the client facade end to end. + * + *

The negotiation-relevant env keys must become observable in the facade behavior: the language selects the Chinese + * templates, and the LLM attempt limit bounds the retry loop of the from-text generation. The negotiation templates are + * classpath-fixed, so a configured local resource root is ignored in {@code classpath} mode: the built-in template wins + * over any local override. A second test proves the zero-configuration defaults (English templates, three attempts, + * built-in resources) work out of the box. + */ +class A2ATClientNegotiationEnvConfigTest { + + private static final String TEST_MOCK_PROVIDER = "test-negotiation-failing-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, FailingClient.class); + } + } + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + private static final String CUSTOM_TEMPLATE_MARKER = "CUSTOM-TEMPLATE-MARKER-7d31"; + + @TempDir + Path tempDir; + + private Logger rootLogger; + + private ListAppender appender; + + @BeforeEach + void attachAppender() { + rootLogger = + (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + appender = new ListAppender<>(); + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + rootLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void languageAndMaxAttemptsAreObservableAndClasspathIgnoresLocalRoot() throws IOException { + Path customRoot = writeCustomRootWithMarkeredTemplate(); + Path envFile = writeEnvFile( + "A2AT_LANGUAGE=zh-CN", + "A2AT_PROMPT_SOURCE_TYPE=classpath", + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=" + customRoot.toString().replace('\\', '/'), + "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, + "A2AT_LLM_MODEL=example-model", + "A2AT_LLM_BASE_URL=https://llm.example.test/v1", + "A2AT_LLM_API_KEY=test-key", + "A2AT_LLM_MAX_ATTEMPTS=2", + "A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory"); + A2ATClient client = new A2ATClient(envFile); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + assertTrue(result.promptText().contains("所需信息项"), "the zh-CN language must select the Chinese templates"); + assertFalse( + result.promptText().contains(CUSTOM_TEMPLATE_MARKER), + "the configured local resource root must be ignored in classpath mode and the built-in template used"); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> client.generateNegotiationProposePromptFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI)); + assertEquals(A2ATErrorCodes.NEGOTIATION_LLM_INFRASTRUCTURE_ERROR, failure.getCode()); + assertEquals( + 1, + retryEventCount(), + "A2AT_LLM_MAX_ATTEMPTS=2 must allow exactly one retry before surfacing the failure"); + assertEquals(1, exhaustedEventCount(), "the retry loop must report exhaustion exactly once"); + } + + @Test + void zeroConfigDefaultsWorkOutOfTheBox() throws IOException { + Path envFile = writeEnvFile( + "A2AT_LLM_PROVIDER=" + TEST_MOCK_PROVIDER, + "A2AT_LLM_MODEL=example-model", + "A2AT_LLM_BASE_URL=https://llm.example.test/v1", + "A2AT_LLM_API_KEY=test-key"); + A2ATClient client = new A2ATClient(envFile); + + MetadataContent result = client.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE_URI); + + assertTrue(result.promptText().contains("## Information Negotiation"), "the default language must be en-US"); + assertTrue(result.promptText().contains("Required Information Items")); + assertEquals(7, negotiationPrompts(client).size(), "the built-in resources must be used by default"); + assertTrue(client.getPrompt(INFORMATION_PROPOSE_URI).isPresent()); + + assertThrows( + NegotiationGenerationException.class, + () -> client.generateNegotiationProposePromptFromText( + "Provide the ran-energy-saving region.", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI)); + assertEquals( + 2, + retryEventCount(), + "the default attempt limit of 3 must allow exactly two retries before surfacing the failure"); + assertEquals(1, exhaustedEventCount()); + } + + private Path writeEnvFile(String... lines) throws IOException { + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, String.join("\n", lines) + "\n", StandardCharsets.UTF_8); + return envFile; + } + + private static List negotiationPrompts(A2ATClient client) { + return client.getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + + private Path writeCustomRootWithMarkeredTemplate() throws IOException { + Path customTemplate = tempDir.resolve("custom-root") + .resolve("templates") + .resolve("Negotiation-T") + .resolve("information-negotiation") + .resolve("propose") + .resolve("v1") + .resolve("zh-CN") + .resolve("template.md"); + Files.createDirectories(customTemplate.getParent()); + Files.writeString( + customTemplate, + builtinTemplate(INFORMATION_PROPOSE_URI, "zh-CN") + "\n\n## 自定义标记\n" + CUSTOM_TEMPLATE_MARKER + "\n", + StandardCharsets.UTF_8); + return tempDir.resolve("custom-root"); + } + + private static String builtinTemplate(String templateUri, String language) throws IOException { + String[] segments = templateUri.split("/"); + String typeSegment = segments[1]; + String phaseSegment = segments[2]; + String classpathPath = "prompt_resources/templates/Negotiation-T/" + typeSegment + "/" + phaseSegment + "/" + + TemplateUri.DEFAULT_TEMPLATE_VERSION + "/" + language + "/template.md"; + InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(classpathPath); + assertFalse(stream == null, "the built-in template must exist on the classpath: " + classpathPath); + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private long retryEventCount() { + return countEvents("negotiation_llm_retry"); + } + + private long exhaustedEventCount() { + return countEvents("negotiation_llm_retry_exhausted"); + } + + private long countEvents(String eventName) { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.startsWith(eventName + " ")) + .count(); + } + + public static final class FailingClient implements LLMClient { + + private final LLMClientConfig config; + + public FailingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + throw new LLMRuntimeError("test mock failure"); + } + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java new file mode 100644 index 00000000..7db9013b --- /dev/null +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java @@ -0,0 +1,176 @@ +package net.openan.a2at.sdk.client; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class A2ATClientObservabilityTest { + + private static final String MOCK_PROVIDER = "a2at-observability-mock"; + + public static final class NoopLLM implements LLMClient { + public NoopLLM(LLMClientConfig config) {} + + @Override + public LLMResponse structured( + List> messages, Map schema, Double temperature, Integer maxTokens) { + return new LLMResponse("{}", "mock", Map.of(), Map.of()); + } + } + + static final class RecordingHook implements TelemetryHook { + final List started = new ArrayList<>(); + final List succeeded = new ArrayList<>(); + final List rounds = new ArrayList<>(); + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + String name = role + "." + method; + started.add(name); + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + + @Override public void markSuccess() { + succeeded.add(name); + } + + @Override public void markError(Throwable error) { + started.add("FAILED:" + name); + } + + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + } + + static final class ThrowingHook implements TelemetryHook { + private final boolean throwOnStart; + + ThrowingHook(boolean throwOnStart) { + this.throwOnStart = throwOnStart; + } + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + if (throwOnStart) { + throw new IllegalStateException("startSdkOperation failed"); + } + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + + @Override public void markSuccess() {} + + @Override public void markError(Throwable error) { + throw new IllegalStateException("markError failed"); + } + + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) {} + } + + @TempDir + Path tempDir; + + private A2ATClient newClient(TelemetryHook hook) throws IOException { + if (!LLMClientFactory.availableProviders().contains(MOCK_PROVIDER)) { + LLMClientFactory.register(MOCK_PROVIDER, NoopLLM.class); + } + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(MOCK_PROVIDER)); + return new A2ATClient(envFile, hook); + } + + @Test + void llmFreePathIsWrappedAsSdkOperation() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATClient client = newClient(hook); + client.getPrompts(); + assertTrue(hook.started.contains("client.getPrompts")); + assertTrue(hook.succeeded.contains("client.getPrompts")); + } + + @Test + void runtimeFailureMarksErrorAndPropagates() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATClient client = newClient(hook); + assertThrows(NullPointerException.class, () -> client.generateTaskPromptFromText(null, null)); + assertTrue(hook.started.contains("client.generateTaskPromptFromText")); + assertTrue(hook.started.contains("FAILED:client.generateTaskPromptFromText")); + } + + @Test + void throwingHookDoesNotBreakBusinessFlow() throws IOException { + A2ATClient startFailure = newClient(new ThrowingHook(true)); + assertDoesNotThrow(startFailure::getPrompts); + + A2ATClient markErrorFailure = newClient(new ThrowingHook(false)); + NullPointerException error = assertThrows( + NullPointerException.class, () -> markErrorFailure.generateTaskPromptFromText(null, null)); + assertEquals("text", error.getMessage()); + } + + @Test + void legacyConstructorAndFacadeCallsComplete() throws IOException { + if (!LLMClientFactory.availableProviders().contains(MOCK_PROVIDER)) { + LLMClientFactory.register(MOCK_PROVIDER, NoopLLM.class); + } + Path envFile = tempDir.resolve("client-legacy.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(MOCK_PROVIDER)); + A2ATClient client = new A2ATClient(envFile); + assertDoesNotThrow(client::getPrompts); + assertDoesNotThrow(client::getPrompts); + } + + @Test + void terminalOutcomeMapping() { + assertEquals("accept", A2ATClient.terminalOutcome(NegotiationStatus.AGREED)); + assertEquals("reject", A2ATClient.terminalOutcome(NegotiationStatus.REJECTED)); + assertEquals(null, A2ATClient.terminalOutcome(NegotiationStatus.IN_PROGRESS)); + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index c5af83de..c7ef7571 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -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; @@ -9,21 +10,51 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; class A2ATClientTest { + private static final String TEST_MOCK_PROVIDER = "test-a2at-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + @BeforeEach + void resetRecordingClientState() { + RecordingClient.SLOT_OVERRIDES.clear(); + RecordingClient.REQUEST_COUNT.set(0); + } + @Test - void onlyExposesPathBasedPublicConstructor() throws NoSuchMethodException { + void onlyExposesPathBasedPublicConstructors() throws NoSuchMethodException { assertNotNull(A2ATClient.class.getConstructor(Path.class)); - assertEquals(1, A2ATClient.class.getConstructors().length); - assertEquals(1, A2ATClient.class.getDeclaredConstructors().length); + assertNotNull(A2ATClient.class.getConstructor(Path.class, net.openan.a2at.sdk.core.telemetry.TelemetryHook.class)); + assertEquals(2, A2ATClient.class.getConstructors().length); + assertEquals(2, A2ATClient.class.getDeclaredConstructors().length); } @Test @@ -68,6 +99,8 @@ void noLongerExposesLowLevelPromptGenerationAssemblyConstructors() { void doesNotKeepStaticAssemblyHelpersInsideFacade() { long staticMethodCount = Arrays.stream(A2ATClient.class.getDeclaredMethods()) .filter(method -> java.lang.reflect.Modifier.isStatic(method.getModifiers())) + .filter(method -> !java.lang.reflect.Modifier.isPrivate(method.getModifiers())) + .filter(method -> !"terminalOutcome".equals(method.getName())) .count(); assertEquals(0, staticMethodCount); @@ -76,6 +109,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 = @@ -83,22 +118,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, "local_rule"); + 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 @@ -111,15 +184,18 @@ void pathBasedConstructorLoadsClasspathPromptResources() throws IOException { } @Test - void pathBasedConstructorAcceptsOpenAiCompatibleProvider() throws IOException { - Path envFile = writeMinimalClientEnv("openai_compatible"); + 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 @@ -127,27 +203,26 @@ void pathBasedConstructorBuildsDefaultNegotiationRuntime() throws IOException { Path envFile = writeMinimalLocalClientEnv(); A2ATClient client = new A2ATClient(envFile); - Map startResult = client.startNegotiation( - NegotiationType.CLARIFICATION, "Please clarify the target.", Map.of("site", "A")); + Map startResult = + client.startNegotiation(NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); @SuppressWarnings("unchecked") - Map context = (Map) - startResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_CONTEXT_KEY); + Map startData = (Map) + startResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_T_URI_NL); Map continueResult = client.continueNegotiation( new NegotiationContext( - NegotiationType.CLARIFICATION, - String.valueOf(context.get("negotiationId")), + 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.", - startResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_TEXT_KEY)); - assertEquals( - "Site A", - continueResult.get(net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler.NEGOTIATION_TEXT_KEY)); + assertEquals("Please clarify the target.", startData.get("message")); + assertEquals("Site A", continueData.get("message")); } @Test @@ -157,21 +232,40 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); Path templatesDir = promptRoot .resolve("templates") - .resolve("private_line_complaint") + .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("private_line_complaint").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_code": "private-line-complaint", "scenario_name": "Private Line Complaint", "description": "Complaint analysis", "example": "Analyze private line fault" @@ -203,19 +297,316 @@ void pathBasedConstructorSupportsNonEnergySavingLocalScenarioCatalog() throws IO A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=local_file A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=local_rule - A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory - """); +A2AT_LLM_PROVIDER=%s +A2AT_LLM_MODEL=example-model +A2AT_LLM_BASE_URL=https://llm.example.test/v1 +A2AT_LLM_API_KEY=test-key +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(TEST_MOCK_PROVIDER)); + RecordingClient.SLOT_OVERRIDES.put("line_id", "line-1"); + RecordingClient.SLOT_OVERRIDES.put("fault_id", "fault-9"); A2ATClient client = new A2ATClient(envFile); PromptGenerationResult result = client.generateTaskPrompt(Map.of("line_id", "line-1", "fault_id", "fault-9")); assertTrue(result.success()); assertEquals("Line: line-1\\nFault: fault-9", result.promptText()); + assertTrue(RecordingClient.REQUEST_COUNT.get() >= 1, "Map input must trigger LLM extraction"); + } + + @Test + void fromTextEntryPointsReturnMetadataContent() throws IOException { + Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); + A2ATClient client = new A2ATClient(envFile); + String unconstrained = "Task-T/network-layer/unconstrained/v1"; + + MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); + MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); + MetadataContent notificationResult = + client.generateNotificationPromptFromText("Report finished.", unconstrained); + + assertNotNull(taskResult); + assertEquals("Task-T/network-layer/unconstrained/v1", taskResult.templateUri()); + assertNotNull(taskResult.promptText()); + assertNotNull(taskResult.extensionUri()); + + assertNotNull(authResult); + assertEquals("Task-T/network-layer/unconstrained/v1", authResult.templateUri()); + assertNotNull(authResult.promptText()); + assertNotNull(authResult.extensionUri()); + + assertNotNull(notificationResult); + assertEquals("Task-T/network-layer/unconstrained/v1", notificationResult.templateUri()); + assertNotNull(notificationResult.promptText()); + assertNotNull(notificationResult.extensionUri()); + } + + @Test + void fromDataWithSchemaEntryPointsReturnMetadataContent() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); + Map schema = Map.of("type", "object"); + + MetadataContent taskResult = + client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); + MetadataContent authResult = + client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); + MetadataContent notificationResult = + client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); + + assertNotNull(taskResult); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), taskResult.templateUri()); + assertNotNull(taskResult.promptText()); + assertNotNull(taskResult.extensionUri()); + + assertNotNull(authResult); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), authResult.templateUri()); + assertNotNull(authResult.promptText()); + assertNotNull(authResult.extensionUri()); + + assertNotNull(notificationResult); + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), notificationResult.templateUri()); + assertNotNull(notificationResult.promptText()); + assertNotNull(notificationResult.extensionUri()); + } + + @Test + void fromTextReturnsCorrectExtensionUriPerContentType() throws IOException { + Path envFile = writeMinimalClientEnvWithoutRequiredSlots(TEST_MOCK_PROVIDER); + A2ATClient client = new A2ATClient(envFile); + String unconstrained = "Task-T/network-layer/unconstrained/v1"; + + MetadataContent taskResult = client.generateTaskPromptFromText("Please analyze Site A.", unconstrained); + MetadataContent authResult = client.generateAuthPromptFromText("Authorize access.", unconstrained); + MetadataContent notificationResult = + client.generateNotificationPromptFromText("Report finished.", unconstrained); + + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notificationResult.extensionUri()); + } + + @Test + void fromDataWithSchemaReturnsCorrectExtensionUriPerContentType() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A", "target", "Reduce power by 10%"); + Map schema = Map.of("type", "object"); + + MetadataContent taskResult = + client.generateTaskPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); + MetadataContent authResult = + client.generateAuthPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); + MetadataContent notificationResult = + client.generateNotificationPromptFromDataWithSchema(data, schema, StandardTemplates.ENERGY_SAVING_URI); + + assertEquals(ExtensionUriConstants.TASK_T_EXTENSION_URI, taskResult.extensionUri()); + assertEquals(ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI, authResult.extensionUri()); + assertEquals(ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI, notificationResult.extensionUri()); + } + + @Test + void fromTextThrowsOnNullTemplateUri() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + NullPointerException taskEx = assertThrows( + NullPointerException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", null)); + assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException authEx = assertThrows( + NullPointerException.class, () -> client.generateAuthPromptFromText("Authorize access.", null)); + assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException notifEx = assertThrows( + NullPointerException.class, () -> client.generateNotificationPromptFromText("Report finished.", null)); + assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); + } + + @Test + void fromDataWithSchemaThrowsOnNullTemplateUri() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + Map data = Map.of("site", "Site A"); + Map schema = Map.of("type", "object"); + + NullPointerException taskEx = assertThrows( + NullPointerException.class, () -> client.generateTaskPromptFromDataWithSchema(data, schema, null)); + assertFalse(A2ATError.class.isInstance(taskEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException authEx = assertThrows( + NullPointerException.class, () -> client.generateAuthPromptFromDataWithSchema(data, schema, null)); + assertFalse(A2ATError.class.isInstance(authEx), "null template URI must stay outside the A2ATError tree"); + NullPointerException notifEx = assertThrows( + NullPointerException.class, + () -> client.generateNotificationPromptFromDataWithSchema(data, schema, null)); + assertFalse(A2ATError.class.isInstance(notifEx), "null template URI must stay outside the A2ATError tree"); + } + + @Test + void fromTextThrowsOnMalformedTemplateUriString() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + for (String malformed : new String[] {"not-a-uri", "Task-T/only-one-segment", "Task-T/../v1"}) { + IllegalArgumentException taskEx = assertThrows( + IllegalArgumentException.class, + () -> client.generateTaskPromptFromText("Please analyze Site A.", malformed)); + assertTrue(taskEx.getMessage().contains("Unparseable template URI"), "message was: " + taskEx.getMessage()); + IllegalArgumentException authEx = assertThrows( + IllegalArgumentException.class, () -> client.generateAuthPromptFromText("Authorize access.", malformed)); + assertTrue(authEx.getMessage().contains("Unparseable template URI"), "message was: " + authEx.getMessage()); + IllegalArgumentException notifEx = assertThrows( + IllegalArgumentException.class, + () -> client.generateNotificationPromptFromText("Report finished.", malformed)); + assertTrue(notifEx.getMessage().contains("Unparseable template URI"), "message was: " + notifEx.getMessage()); + } + } + + @Test + void fromTextThrowsOnBlankTemplateUriString() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + IllegalArgumentException taskEx = assertThrows( + IllegalArgumentException.class, () -> client.generateTaskPromptFromText("Please analyze Site A.", " ")); + assertTrue(taskEx.getMessage().contains("Unparseable template URI"), "message was: " + taskEx.getMessage()); + } + + @Test + void getPromptThrowsOnMalformedAndBlankTemplateUriString() throws IOException { + Path envFile = writeMinimalLocalClientEnv(); + A2ATClient client = new A2ATClient(envFile); + + IllegalArgumentException malformedEx = + assertThrows(IllegalArgumentException.class, () -> client.getPrompt("not-a-uri")); + assertTrue(malformedEx.getMessage().contains("Unparseable template URI"), "message was: " + malformedEx.getMessage()); + IllegalArgumentException blankEx = assertThrows(IllegalArgumentException.class, () -> client.getPrompt(" ")); + assertTrue(blankEx.getMessage().contains("Unparseable template URI"), "message was: " + blankEx.getMessage()); + } + + @Test + void fromTextRejectsOverLimitTextWhenConfiguredWithSmallLimit() throws IOException { + Path envFile = writeMinimalClientEnvWithInputLimit(TEST_MOCK_PROVIDER, 100); + A2ATClient client = new A2ATClient(envFile); + String overLimitText = "a".repeat(101); + + PromptGenerationException taskEx = assertThrows( + PromptGenerationException.class, + () -> client.generateTaskPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING_URI)); + PromptGenerationException authEx = assertThrows( + PromptGenerationException.class, + () -> client.generateAuthPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING_URI)); + PromptGenerationException notificationEx = assertThrows( + PromptGenerationException.class, + () -> client.generateNotificationPromptFromText(overLimitText, StandardTemplates.ENERGY_SAVING_URI)); + + assertEquals("input.text_too_long", taskEx.getCode()); + assertEquals("input.text_too_long", authEx.getCode()); + assertEquals("input.text_too_long", notificationEx.getCode()); + assertEquals(0, RecordingClient.REQUEST_COUNT.get(), "over-limit input must fail before any LLM call"); + } + + @Test + void generateTaskPromptRejectsOverLimitStringInputWithoutLlmCall() throws IOException { + Path envFile = writeMinimalClientEnvWithInputLimit(TEST_MOCK_PROVIDER, 100); + A2ATClient client = new A2ATClient(envFile); + + PromptGenerationResult result = client.generateTaskPrompt("a".repeat(101)); + + assertFalse(result.success()); + assertEquals("input.text_too_long", result.failure().code()); + assertEquals(0, RecordingClient.REQUEST_COUNT.get(), "over-limit input must fail before any LLM call"); + } + + private static Path writeMinimalClientEnvWithInputLimit(String provider, int maxTextChars) throws IOException { + Path envFile = writeMinimalClientEnv(provider); + Files.writeString( + envFile, "A2AT_INPUT_TEXT_MAX_CHARS=" + maxTextChars + "\n", java.nio.file.StandardOpenOption.APPEND); + return envFile; + } + + private static Path writeMinimalClientEnvWithoutRequiredSlots(String provider) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-client-env-no-required"); + Path promptRoot = tempDir.resolve("prompt_resources"); + Path scenarioPromptDir = + promptRoot.resolve("prompts").resolve("scenario_recognition").resolve("zh-CN"); + Path slotPromptDir = + promptRoot.resolve("prompts").resolve("slot_extraction").resolve("zh-CN"); + Path scenariosDir = promptRoot.resolve("scenarios").resolve("zh-CN"); + Path templatesDir = promptRoot + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("unconstrained") + .resolve("v1") + .resolve("zh-CN"); + Path slotsDir = promptRoot + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("unconstrained") + .resolve("v1") + .resolve("zh-CN"); + Files.createDirectories(scenarioPromptDir); + Files.createDirectories(slotPromptDir); + Files.createDirectories(scenariosDir); + Files.createDirectories(templatesDir); + Files.createDirectories(slotsDir); + + Files.writeString( + scenariosDir.resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "unconstrained", + "scenario_name": "Unconstrained", + "description": "No required slots", + "example": "Analyze unconstrained" + } + ] + } + """); + Files.writeString(templatesDir.resolve("template.md"), "Notes: {notes}\\nFault: {fault_description}"); + Files.writeString( + slotsDir.resolve("slot.json"), + """ + { + "required": [], + "properties": { + "notes": { + "type": "string" + }, + "fault_description": { + "type": "string" + } + } + } + """); + Files.writeString(scenarioPromptDir.resolve("system.md"), "Identify the best matching scenario."); + Files.writeString(scenarioPromptDir.resolve("user.md"), "Choose from the provided scenario list."); + Files.writeString(slotPromptDir.resolve("system.md"), "Extract slots from the input."); + Files.writeString(slotPromptDir.resolve("user.md"), "Return slots as JSON."); + + Path envFile = tempDir.resolve("client.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=local_file + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(provider)); + return envFile; } private static Path writeMinimalLocalClientEnv() throws IOException { - return writeMinimalClientEnv("local_rule"); + return writeMinimalClientEnv(TEST_MOCK_PROVIDER); } private static Path writeMinimalClasspathClientEnv() throws IOException { @@ -227,7 +618,10 @@ private static Path writeMinimalClasspathClientEnv() throws IOException { A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= - A2AT_LLM_PROVIDER=local_rule + A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=example-model +A2AT_LLM_BASE_URL=https://llm.example.test/v1 +A2AT_LLM_API_KEY=test-key A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory """); return envFile; @@ -244,9 +638,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("energy_saving").resolve("zh-CN"); - Path slotsDir = promptRoot.resolve("slots").resolve("energy_saving").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); @@ -259,7 +664,7 @@ private static Path writeMinimalClientEnv(Path tempDir, String provider) throws { "scenarios": [ { - "scenario_code": "energy_saving", + "scenario_code": "ran-energy-saving", "scenario_name": "Energy Saving", "description": "Energy analysis", "example": "Analyze site power" @@ -304,4 +709,134 @@ private static Path writeMinimalClientEnv(Path tempDir, String provider) throws .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/api/AuthorizationEntryPointMetadataTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationEntryPointMetadataTest.java new file mode 100644 index 00000000..68f382fa --- /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_URI); + + 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_URI); + + 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/api/AuthorizationSlotSchemaResourceTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/AuthorizationSlotSchemaResourceTest.java new file mode 100644 index 00000000..13d692ae --- /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("ran-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")); + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptAssemblyPackageStructureTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/ClientPromptAssemblyPackageStructureTest.java index 2506d0fe..8121ea03 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; @@ -18,20 +17,19 @@ class ClientPromptAssemblyPackageStructureTest { @Test void defaultAssemblyStillBuildsAndGeneratesPrompt() { LLMClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"energy_saving\",\"error_message\":null}", + "{\"matched\":true,\"scenario_code\":\"ran-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( + "ran-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 4b2f0fdb..77beddf2 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,20 +2,22 @@ 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.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; 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 { @@ -23,13 +25,13 @@ class ClientPromptGenerationOrchestratorBuilderTest { @Test void buildCreatesStructuredDefaultPromptGenerationAssembly() { RecordingClient llmClient = new RecordingClient( - "{\"matched\":true,\"scenario_code\":\"energy_saving\",\"error_message\":null}", + "{\"matched\":true,\"scenario_code\":\"ran-energy-saving\",\"error_message\":null}", "{\"slots\":{\"site\":\"Site A\",\"additional_notes\":\"critical\",\"limit\":\"5\",\"severity\":\"high\"},\"slot_errors\":[]}"); DefaultClientPromptGenerationOrchestrator orchestrator = ClientPromptGenerationOrchestratorBuilder.builder() .llmClient(llmClient) .scenarios(List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power"))) + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power"))) .language("en-US") .scenarioSystemPrompt("Identify the best matching scenario.") .scenarioUserPrompt("Choose from the provided scenario list.") @@ -53,7 +55,7 @@ void buildCreatesStructuredDefaultPromptGenerationAssembly() { @Test void buildUsesExplicitCollaboratorsWhenProvided() { RecordingScenarioRecognizer scenarioRecognizer = - new RecordingScenarioRecognizer(new ScenarioRecognitionResult(true, "energy_saving", null)); + new RecordingScenarioRecognizer(new ScenarioRecognitionResult(true, "ran-energy-saving", null)); RecordingTemplateLoader templateLoader = new RecordingTemplateLoader("Site: {site}\nNotes: {additional_notes}"); RecordingSlotValueExtractor slotValueExtractor = new RecordingSlotValueExtractor(Map.of("site", "Site B", "additional_notes", "follow-up")); @@ -61,7 +63,7 @@ void buildUsesExplicitCollaboratorsWhenProvided() { DefaultClientPromptGenerationOrchestrator orchestrator = ClientPromptGenerationOrchestratorBuilder.builder() .llmClient(new RecordingClient()) .scenarios(List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power"))) + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power"))) .language("zh-CN") .scenarioSystemPrompt("scenario-system") .scenarioUserPrompt("scenario-user") @@ -80,14 +82,26 @@ void buildUsesExplicitCollaboratorsWhenProvided() { assertEquals("Analyze Site B.", scenarioRecognizer.lastInput); assertEquals("scenario-system", scenarioRecognizer.lastSystemPrompt); assertEquals("scenario-user", scenarioRecognizer.lastUserPrompt); - assertEquals("energy_saving", templateLoader.lastScenarioCode); + assertEquals("ran-energy-saving", templateLoader.lastScenarioCode); assertEquals("zh-CN", templateLoader.lastLanguage); assertSame("Analyze Site B.", slotValueExtractor.lastUserInput); - assertEquals("energy_saving", slotValueExtractor.lastScenarioCode); + assertEquals("ran-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( + "ran-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 ScenarioRecognizer { 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 new file mode 100644 index 00000000..36e2653f --- /dev/null +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/assembly/DefaultA2ATClientBuilderTest.java @@ -0,0 +1,193 @@ +package net.openan.a2at.sdk.client.prompt.assembly; + +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 java.util.concurrent.atomic.AtomicInteger; +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 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); + 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.inputLimits(), config.negotiation(), config.promptCompliance()); + + DefaultA2ATClientBuilder builder = + DefaultA2ATClientBuilder.builder().config(config).envPath(envFile); + + builder.buildPromptGenerationOrchestrator(); + builder.buildNegotiationGenerationOrchestrator(); + + assertEquals(1, CLIENT_INSTANCE_COUNT.get()); + } + + @Test + void buildPromptGenerationOrchestratorFallsBackToBuiltinScenarioCatalog() 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); + 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.inputLimits(), config.negotiation(), config.promptCompliance()); + + DefaultA2ATClientBuilder builder = + DefaultA2ATClientBuilder.builder().config(config).envPath(envFile); + + assertNotNull( + builder.buildPromptGenerationOrchestrator(), + "a missing local scenarios.json must fall back to the built-in scenario catalog instead of failing"); + } + + private static Path createTempEnvFileWithoutScenarioCatalog(String provider) throws IOException { + 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"); + 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": "ran-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()); + } + } +} 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 1818003f..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/DefaultStructuredClientSlotValueExtractorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java deleted file mode 100644 index 7c31b52f..00000000 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultStructuredClientSlotValueExtractorTest.java +++ /dev/null @@ -1,93 +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.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), - new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null), - new PromptSlotDefinition("limit", false, "integer", null, 1.0d, 10.0d, null, null), - new PromptSlotDefinition( - "severity", - false, - "string", - null, - null, - null, - List.of("low", "medium", "high"), - null))), - "Extract slots from the input.", - "Return slots as JSON."); - - Map 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")); - } - - 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 996de021..00000000 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/extractor/DefaultTemplateDrivenSlotValueExtractorTest.java +++ /dev/null @@ -1,162 +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), - new PromptSlotDefinition("additional_notes", false, "string", null, null, null, null, null), - new PromptSlotDefinition("ignored", false, "string", 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))); - - 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), - new PromptSlotDefinition("condition", false, "string", null, null, null, null, null), - new PromptSlotDefinition("report_format", false, "string", 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), - new PromptSlotDefinition("additional_notes", false, "string", 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), - new PromptSlotDefinition("additional_notes", false, "string", null, null, null, null, null), - new PromptSlotDefinition("limit", false, "integer", null, 1.0d, 10.0d, null, null), - new PromptSlotDefinition( - "severity", - false, - "string", - null, - null, - null, - List.of("low", "medium", "high"), - null))); - - 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), - new PromptSlotDefinition("additional_notes", false, "string", null, null, null, null, null), - new PromptSlotDefinition("limit", false, "integer", null, 1.0d, 10.0d, null, null), - new PromptSlotDefinition( - "severity", - false, - "string", - null, - null, - null, - List.of("low", "medium", "high"), - null))); - - 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)); - } -} 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 8ef429f4..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(4, 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 0b7c4254..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 c321eb5e..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(4, schema.slotDefinitions().size()); - - PromptSlotDefinition taskObject = schema.slotDefinitions().get(1); - assertFalse(taskObject.name().isBlank()); - assertEquals("string", taskObject.jsonType()); - assertTrue(taskObject.description().length() > 10); - - PromptSlotDefinition constraints = schema.slotDefinitions().get(3); - assertFalse(constraints.name().isBlank()); - assertTrue(constraints.description().length() > 10); - } -} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java index 66895085..5b3908f1 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/prompt/orchestration/DefaultClientPromptGenerationOrchestratorTest.java @@ -3,49 +3,71 @@ 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.client.prompt.extractor.ClientSlotValueExtractor; -import net.openan.a2at.sdk.client.prompt.loader.ClientTemplateLoader; +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.api.TaskPromptRenderer; +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( - (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "energy_saving", null), + recognizer, List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "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()); + 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.", orchestrator.lastNormalizedInput()); - assertEquals("energy_saving", templateLoader.lastScenarioCode); + assertEquals("Analyze Site A.", recognizer.lastNormalizedInput); + assertEquals("ran-energy-saving", templateLoader.lastScenarioCode); assertEquals("en-US", templateLoader.lastLanguage); assertEquals("Analyze Site A.", slotValueExtractor.lastUserInput); - assertEquals("energy_saving", slotValueExtractor.lastScenarioCode); + assertEquals("ran-energy-saving", slotValueExtractor.lastScenarioCode); assertEquals("en-US", slotValueExtractor.lastLanguage); - assertEquals("Site: {site}\nNotes: {additional_notes}", slotValueExtractor.lastTemplateText); } @Test @@ -54,20 +76,21 @@ void generateTaskPromptReturnsFailureWhenScenarioIsNotMatched() { (normalizedInput, scenarios, systemPrompt, userPrompt) -> new ScenarioRecognitionResult(false, null, "No scenario matched."), List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "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, templateText) -> - Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), - new TaskPromptRenderer()); + (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.not_matched", result.failure().code()); assertEquals("scenario", result.failure().stage()); } @@ -75,24 +98,25 @@ void generateTaskPromptReturnsFailureWhenScenarioIsNotMatched() { void generateTaskPromptReturnsFailureWhenTemplateIsMissing() { DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "energy_saving", null), + new ScenarioRecognitionResult(true, "ran-energy-saving", null), List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "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, templateText) -> - Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), - new TaskPromptRenderer()); + (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("template.not_found", result.failure().code()); assertEquals("generation", result.failure().stage()); } @@ -100,21 +124,23 @@ void generateTaskPromptReturnsFailureWhenTemplateIsMissing() { void generateTaskPromptReturnsFailureWhenRenderingFails() { DefaultClientPromptGenerationOrchestrator orchestrator = new DefaultClientPromptGenerationOrchestrator( (normalizedInput, scenarios, systemPrompt, userPrompt) -> - new ScenarioRecognitionResult(true, "energy_saving", null), + new ScenarioRecognitionResult(true, "ran-energy-saving", null), List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "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, templateText) -> Map.of("scenario", scenarioCode), - new TaskPromptRenderer()); + (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("template.render_failed", result.failure().code()); assertEquals("generation", result.failure().stage()); } @@ -127,24 +153,65 @@ void generateTaskPromptReturnsPromptResourceLoadErrorWhenScenarioPromptsAreMissi "prompt_resources/prompts/scenario_recognition/zh-CN/system.md"); }, List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "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, templateText) -> - Map.of("scenario", scenarioCode, "input", String.valueOf(userInput)), - new TaskPromptRenderer()); + (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("template.load_failed", result.failure().code()); assertEquals("generation", result.failure().stage()); } - private static final class FakeTemplateLoader implements ClientTemplateLoader { + 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; @@ -161,25 +228,962 @@ 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; + 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.lastTemplateText = templateText; - return slots; + 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-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 e51094ca..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-client/src/test/resources/logback-test.xml b/a2a-t-client/src/test/resources/logback-test.xml new file mode 100644 index 00000000..cbe3df3c --- /dev/null +++ b/a2a-t-client/src/test/resources/logback-test.xml @@ -0,0 +1,11 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/a2a-t-client/src/test/resources/prompt_resources/slots/free_text/en-US/slot.json b/a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/network-layer/free-text/v1/en-US/slot.json similarity index 100% rename from a2a-t-client/src/test/resources/prompt_resources/slots/free_text/en-US/slot.json rename to a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/network-layer/free-text/v1/en-US/slot.json diff --git a/a2a-t-client/src/test/resources/prompt_resources/slots/energy_saving/en-US/slot.json b/a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json similarity index 100% rename from a2a-t-client/src/test/resources/prompt_resources/slots/energy_saving/en-US/slot.json rename to a2a-t-client/src/test/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json diff --git a/a2a-t-client/src/test/resources/prompt_resources/templates/energy_saving/en-US/template.md b/a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/en-US/template.md similarity index 100% rename from a2a-t-client/src/test/resources/prompt_resources/templates/energy_saving/en-US/template.md rename to a2a-t-client/src/test/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/en-US/template.md diff --git a/a2a-t-core/pom.xml b/a2a-t-core/pom.xml index b8c782d4..09f62da1 100644 --- a/a2a-t-core/pom.xml +++ b/a2a-t-core/pom.xml @@ -23,5 +23,15 @@ org.apache.commons commons-lang3 + + org.slf4j + slf4j-api + + + ch.qos.logback + logback-classic + 1.4.14 + test + 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..373f2bed --- /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 new file mode 100644 index 00000000..e6e70237 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATError.java @@ -0,0 +1,74 @@ +package net.openan.a2at.sdk.core.exception; + +import java.util.Objects; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Single root exception type for all A2A-T SDK processing failures. + * + *

Every runtime, environment or data-processing failure raised by the SDK is part of this tree, so callers can catch + * this one root type and branch on the machine-readable error code returned by {@link #getCode()}, which is never + * {@code null}. Caller contract violations (null, blank or otherwise malformed arguments) are programming errors raised + * as {@link NullPointerException} or {@link IllegalArgumentException} and intentionally stay outside this tree. + * + * @since 2026-08 + */ +public class A2ATError extends RuntimeException { + + /** Machine-readable error code for this failure, never null. */ + private final String code; + + /** + * Creates an A2A-T processing failure with one message and the default error code. + * + * @param message failure message + */ + public A2ATError(String message) { + this(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), message); + } + + /** + * Creates an A2A-T processing failure with one message, one root cause and the default error code. + * + * @param message failure message + * @param cause root cause + */ + public A2ATError(String message, @Nullable Throwable cause) { + this(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), message, cause); + } + + /** + * Creates an A2A-T processing failure with one specific error code and one message. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @throws NullPointerException if {@code code} is null + */ + public A2ATError(@NonNull String code, String message) { + super(message); + this.code = Objects.requireNonNull(code, "code must not be null"); + } + + /** + * Creates an A2A-T processing failure with one specific error code, one message and one root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param cause root cause + * @throws NullPointerException if {@code code} is null + */ + public A2ATError(@NonNull String code, String message, @Nullable Throwable cause) { + super(message, cause); + this.code = Objects.requireNonNull(code, "code must not be null"); + } + + /** + * Returns the machine-readable error code for this failure. + * + * @return error code, never null + */ + public @NonNull String getCode() { + return code; + } +} 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 new file mode 100644 index 00000000..75476350 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodes.java @@ -0,0 +1,102 @@ +package net.openan.a2at.sdk.core.exception; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Centralized machine-readable error code constants shared across A2A-T SDK processing failures. + * + * @deprecated superseded by {@link ErrorCatalog}; each constant forwards to the layered {@code domain.semantic} code of + * the catalog entry that replaced it. Kept only while the corpus and sample modules reference the old constant + * names; new code must use {@link ErrorCatalog} directly. + * @since 2026-08 + */ +@Deprecated(forRemoval = true) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class A2ATErrorCodes { + + /** Default code for SDK processing failures without a more specific code. */ + @Deprecated(forRemoval = true) + public static final String SDK_INTERNAL_ERROR = ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(); + + /** A free-text input exceeded the configured maximum length. */ + @Deprecated(forRemoval = true) + public static final String INPUT_TEXT_TOO_LONG = ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(); + + /** Default code for parameter-extraction failures without a more specific code. */ + @Deprecated(forRemoval = true) + public static final String PARAM_EXTRACTION_FAILED = ErrorCatalog.SLOT_NOT_PROVIDED.getCode(); + + /** A referenced template could not be resolved. */ + @Deprecated(forRemoval = true) + public static final String TEMPLATE_NOT_FOUND = ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); + + /** Structured content could not be extracted from a free-text input. */ + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_CONTENT_EXTRACT_FAILED = + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(); + + /** Semantic validation rejected the input. */ + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_SEMANTIC_REJECTED = ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(); + + /** One or more structural rules of the expected template were violated. */ + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_RULE_VIOLATION = ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(); + + /** A required slot is missing from the input. */ + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_SLOT_MISSING = ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(); + + /** The input is not valid for the requested operation. */ + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_INVALID_INPUT = ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(); + + /** An LLM infrastructure failure prevented the step from completing. */ + @Deprecated(forRemoval = true) + public static final String NEGOTIATION_LLM_INFRASTRUCTURE_ERROR = ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); + + /** The input is not valid for the requested validation operation. */ + @Deprecated(forRemoval = true) + public static final String VALIDATION_INVALID_INPUT = ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(); + + /** One or more structural rules of the expected content were violated. */ + @Deprecated(forRemoval = true) + public static final String VALIDATION_RULE_VIOLATION = ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(); + + /** Semantic validation rejected the content. */ + @Deprecated(forRemoval = true) + public static final String VALIDATION_SEMANTIC_REJECTED = ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(); + + /** An LLM infrastructure failure prevented the validation step from completing. */ + @Deprecated(forRemoval = true) + public static final String VALIDATION_LLM_INFRASTRUCTURE_ERROR = ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); + + /** A referenced prompt resource could not be resolved. */ + @Deprecated(forRemoval = true) + public static final String VALIDATION_PROMPT_RESOURCE_NOT_FOUND = ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(); + + /** A prompt resource could not be loaded from its configured source. */ + @Deprecated(forRemoval = true) + public static final String PROMPT_RESOURCE_LOAD_ERROR = ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode(); + + /** The slot schema describing the expected slots could not be resolved. */ + @Deprecated(forRemoval = true) + public static final String SLOT_SCHEMA_NOT_FOUND = ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(); + + /** An LLM invocation failed at the infrastructure level. */ + @Deprecated(forRemoval = true) + public static final String LLM_INVOCATION_FAILED = ErrorCatalog.LLM_INVOCATION_FAILED.getCode(); + + /** Rendering a template with its slot values failed. */ + @Deprecated(forRemoval = true) + public static final String RENDER_FAILED = ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode(); + + /** The slot values extracted or provided for a template failed validation. */ + @Deprecated(forRemoval = true) + public static final String SLOT_VALIDATION_ERROR = ErrorCatalog.SLOT_RULE_VIOLATION.getCode(); + + /** A processed prompt could not be parsed into its structured parts. */ + @Deprecated(forRemoval = true) + public static final String PROCESSED_PROMPT_PARSE_ERROR = ErrorCatalog.SCENARIO_NOT_MATCHED.getCode(); +} 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 new file mode 100644 index 00000000..2e0d71d6 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/A2ATParamExtractionError.java @@ -0,0 +1,64 @@ +package net.openan.a2at.sdk.core.exception; + +import java.util.List; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import org.jspecify.annotations.NonNull; + +/** + * Shared failure type raised when validating a prompt and extracting parameters from it fails. + * + *

This base type is shared across prompt families so that a caller can handle extraction failures uniformly. The + * machine-readable error code is carried by the root {@link A2ATError} and is never null; subclasses pass a more + * specific code when one is available. + * + * @since 2026-08 + */ +public class A2ATParamExtractionError extends A2ATBusinessException { + + /** Structured per-slot validation error details. */ + private final List errors; + + /** + * Creates a parameter-extraction failure with the default error code and no slot details. + * + * @param message failure message + */ + public A2ATParamExtractionError(String message) { + this(ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), message, List.of()); + } + + /** + * Creates a parameter-extraction failure with one specific error code and slot details. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + */ + public A2ATParamExtractionError(@NonNull String code, String message, @NonNull List errors) { + super(code, message); + this.errors = List.copyOf(errors); + } + + /** + * Creates a parameter-extraction failure with one specific error code, slot details and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + * @param cause root cause of the failure + */ + public A2ATParamExtractionError( + @NonNull String code, String message, @NonNull List errors, Throwable cause) { + super(code, message, null, cause); + this.errors = List.copyOf(errors); + } + + /** + * Returns the structured per-slot validation error details. + * + * @return immutable list of slot validation errors, never null + */ + public @NonNull List getErrors() { + return errors; + } +} 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..6dcd19a2 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/ConfigFileNotFoundException.java @@ -1,20 +1,27 @@ package net.openan.a2at.sdk.core.exception; import java.nio.file.Path; +import java.util.Map; +import org.jspecify.annotations.NonNull; /** * Thrown when one required configuration file path does not exist. * * @since 2026-06 */ -public final class ConfigFileNotFoundException extends SdkException { +public final class ConfigFileNotFoundException extends A2ATError { /** * Creates one exception for one missing config file. * * @param path missing config file path */ - public ConfigFileNotFoundException(Path path) { - super("Config file does not exist: " + 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..f890661a --- /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..a623582b --- /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 new file mode 100644 index 00000000..d8300f07 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/exception/PromptGenerationException.java @@ -0,0 +1,66 @@ +package net.openan.a2at.sdk.core.exception; + +import java.util.List; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Unified exception type for MetadataContent pipeline failures. + * + *

The machine-readable error code is carried by the root {@link A2ATError} and is never null. + * + * @since 2026-08 + */ +public final class PromptGenerationException extends A2ATBusinessException { + + private final List failedParameters; + + /** + * Creates an exception with a stable error code and a human-readable message. + * + * @param code stable error code + * @param message human-readable failure description + * @throws NullPointerException if {@code code} is null + */ + public PromptGenerationException(@NonNull String code, String message) { + super(code, message); + this.failedParameters = List.of(); + } + + /** + * Creates an exception with a stable error code, a human-readable message, and a list of failed parameters. + * + * @param code stable error code + * @param message human-readable failure description + * @param failedParameters list of failed slot validations + * @throws NullPointerException if {@code code} is null + */ + public PromptGenerationException( + @NonNull String code, String message, @Nullable List failedParameters) { + super(code, message); + this.failedParameters = failedParameters == null ? List.of() : List.copyOf(failedParameters); + } + + /** + * Creates an exception with a stable error code, a human-readable message, and a root cause. + * + * @param code stable error code + * @param message human-readable failure description + * @param cause root cause + * @throws NullPointerException if {@code code} is null + */ + public PromptGenerationException(@NonNull String code, String message, @Nullable Throwable cause) { + super(code, message, null, cause); + this.failedParameters = List.of(); + } + + /** + * Returns the list of failed parameters, or an empty list when not applicable. + * + * @return failed parameters + */ + public @NonNull List failedParameters() { + return failedParameters; + } +} 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..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,11 +1,13 @@ package net.openan.a2at.sdk.core.exception; +import org.jspecify.annotations.NonNull; + /** * Raised when a referenced SDK resource cannot be resolved. * * @since 2026-06 */ -public final class ResourceNotFoundException extends SdkException { +public final class ResourceNotFoundException extends A2ATError { private final String resourcePath; @@ -15,7 +17,7 @@ public final class ResourceNotFoundException extends SdkException { * @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/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/A2ATConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java index cda1cb10..28960e17 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfig.java @@ -2,16 +2,18 @@ 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. + * 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) { @@ -21,12 +23,51 @@ 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), LlmConfig.fromMap(values), + InputLimitConfig.fromMap(values), NegotiationConfig.fromMap(values), PromptComplianceConfig.fromMap(values)); } + + /** + * Resolves the relative prompt resource local root directory against the `.env` file location. + * + *

The returned config carries the local root as an absolute normalized path: a relative configured root is + * resolved against the `.env` file parent, an absolute configured root is only normalized. An unset (null or blank) + * local root is preserved as-is, since only {@code local_file} mode consumes the value (and enforces its presence + * at assembly time). + * + * @param config unified SDK config as loaded from the `.env` file + * @param envPath resolved `.env` file path the config was loaded from; a relative path is absolved against the + * current working directory before its parent is derived + * @return config with the local root resolved to an absolute normalized path, or unchanged when the local root is + * unset (null or blank) + */ + public static @NonNull A2ATConfig resolvePromptResourceLocalRootDir( + @NonNull A2ATConfig config, @NonNull Path envPath) { + String localRootDir = config.prompt().localRootDir(); + if (localRootDir == null || localRootDir.isBlank()) { + return config; + } + Path localRootPath = Path.of(localRootDir); + Path resolvedLocalRootPath = localRootPath.isAbsolute() + ? localRootPath.normalize() + : envBaseDir(envPath).resolve(localRootPath).toAbsolutePath().normalize(); + return new A2ATConfig( + new PromptRuntimeConfig( + config.prompt().language(), config.prompt().sourceType(), resolvedLocalRootPath.toString()), + config.llm(), + config.inputLimits(), + config.negotiation(), + config.promptCompliance()); + } + + private static Path envBaseDir(Path envPath) { + Path parent = envPath.toAbsolutePath().normalize().getParent(); + return parent != null ? parent : Path.of("").toAbsolutePath(); + } } diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java index f3f66a6a..19c544f3 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/A2ATConfigKeys.java @@ -4,17 +4,15 @@ import lombok.NoArgsConstructor; /** - * Centralized configuration key constants for A2A-T SDK environment variables. - * These keys correspond to entries in the {@code .env} file loaded by {@link DotEnvConfigSource}. + * Centralized configuration key constants for A2A-T SDK environment variables. These keys correspond to entries in the + * {@code .env} file loaded by {@link DotEnvConfigSource}. * * @since 2026-06 */ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class A2ATConfigKeys { - /** - * Prompt runtime configuration keys. - */ + /** Prompt runtime configuration keys. */ @NoArgsConstructor(access = AccessLevel.PRIVATE) public static final class PromptRuntime { @@ -28,9 +26,7 @@ public static final class PromptRuntime { public static final String LOCAL_ROOT_DIR = "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"; } - /** - * LLM runtime configuration keys. - */ + /** LLM runtime configuration keys. */ @NoArgsConstructor(access = AccessLevel.PRIVATE) public static final class Llm { @@ -58,16 +54,29 @@ public static final class Llm { /** 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"; + + /** Whether to verify the TLS certificate chain and hostname of the LLM endpoint (default true). */ + public static final String SSL_VERIFY = "A2AT_LLM_SSL_VERIFY"; + + /** Whether to print the full LLM request and response payloads (no truncation). Default false. */ + public static final String DETAIL_LOG_ENABLED = "A2AT_LLM_DETAIL_LOG_ENABLED"; + + /** Optional reasoning effort level for reasoning models (none/minimal/low/medium/high/xhigh). */ + public static final String REASONING_EFFORT = "A2AT_LLM_REASONING_EFFORT"; + /** Maximum total number of tracked sessions. */ public static final String SESSION_MAX_TOTAL = "A2AT_LLM_SESSION_MAX_TOTAL"; /** Maximum number of tracked sessions per provider. */ public static final String SESSION_MAX_PER_PROVIDER = "A2AT_LLM_SESSION_MAX_PER_PROVIDER"; + + /** Maximum number of attempts for retryable LLM steps (range 1-10, default 3). */ + public static final String MAX_ATTEMPTS = "A2AT_LLM_MAX_ATTEMPTS"; } - /** - * Negotiation runtime configuration keys. - */ + /** Negotiation runtime configuration keys. */ @NoArgsConstructor(access = AccessLevel.PRIVATE) public static final class Negotiation { @@ -75,9 +84,15 @@ public static final class Negotiation { public static final String STATE_STORE_TYPE = "A2AT_NEGOTIATION_STATE_STORE_TYPE"; } - /** - * Prompt compliance configuration keys. - */ + /** 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/DotEnvConfigSource.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/DotEnvConfigSource.java index 16733ac6..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 @@ -8,8 +8,9 @@ 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; +import org.jspecify.annotations.NonNull; /** * Reads SDK configuration from one caller-supplied `.env` file. @@ -27,9 +28,9 @@ 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) { + public static @NonNull Map load(@NonNull Path path) { if (!Files.exists(path)) { throw new ConfigFileNotFoundException(path); } @@ -54,13 +55,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/ExtensionUriConstants.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/ExtensionUriConstants.java new file mode 100644 index 00000000..3b440d47 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/ExtensionUriConstants.java @@ -0,0 +1,44 @@ +package net.openan.a2at.sdk.core.model; + +/** + * Extension URI constants for the A2A-T protocol. + * + *

These URIs identify the A2A-T extension protocols supported by this SDK. + * + * @since 2026-08 + */ +public final class ExtensionUriConstants { + + private ExtensionUriConstants() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** URI for the Task-T extension. */ + public static final String TASK_T_EXTENSION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; + + /** URI for the Authorization-T extension. */ + public static final String AUTHORIZATION_T_EXTENSION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1"; + + /** URI for the Notification-T extension. */ + public static final String NOTIFICATION_T_EXTENSION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + /** + * Canonical URI for the Negotiation-T extension. + * + *

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

Kept for runtime compatibility reads of messages emitted under the NL naming; new metadata emission uses + * {@link #NEGOTIATION_T_EXTENSION_URI}. + */ + public static final String NEGOTIATION_T_EXTENSION_URI_NL = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/NL/v1"; +} \ No newline at end of file diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/FilledParamData.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/FilledParamData.java new file mode 100644 index 00000000..06e4c577 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/FilledParamData.java @@ -0,0 +1,11 @@ +package net.openan.a2at.sdk.core.model; + +import java.util.Map; + +/** + * Successful result of validating content and extracting parameters from it. + * + * @param data parameters validated against and extracted from the content, filled per the caller-provided schema + * @since 2026-08 + */ +public record FilledParamData(Map data) {} \ No newline at end of file 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..3926fa9a --- /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 = 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={}" + + " 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/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java index 28540e8e..a48e67c2 100644 --- a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/LlmConfig.java @@ -1,58 +1,234 @@ package net.openan.a2at.sdk.core.model; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.math.NumberUtils; - +import java.util.ArrayList; +import java.util.List; 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. * * @since 2026-06 */ +@Slf4j public record LlmConfig( String provider, String model, String apiKey, String baseUrl, int historyWindow, - int maxTokens, - double temperature, - double timeoutSeconds, + @Nullable Integer maxTokens, + @Nullable Double temperature, + @Nullable Double timeoutSeconds, int sessionMaxTotal, - int sessionMaxPerProvider) { + int sessionMaxPerProvider, + boolean disableSystemProxy, + boolean sslVerify, + boolean detailLogEnabled, + @Nullable String reasoningEffort, + int maxAttempts, + List parseErrors) { - private static final String DEFAULT_PROVIDER = "openai_compatible"; + private static final String DEFAULT_PROVIDER = "openai"; - private static final int DEFAULT_HISTORY_WINDOW = 12; + private static final int DEFAULT_HISTORY_WINDOW = 10; - private static final int DEFAULT_MAX_TOKENS = 2048; + private static final int DEFAULT_SESSION_MAX_TOTAL = 300; - private static final double DEFAULT_TEMPERATURE = 0.2d; + private static final int DEFAULT_SESSION_MAX_PER_PROVIDER = 100; - private static final double DEFAULT_TIMEOUT_SECONDS = 30.0d; + public static final int DEFAULT_MAX_ATTEMPTS = 3; - private static final int DEFAULT_SESSION_MAX_TOTAL = 300; + private static final int MAX_ATTEMPTS_LOWER_BOUND = 1; - private static final int DEFAULT_SESSION_MAX_PER_PROVIDER = 100; + 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); + } + + /** Pre-{@code sslVerify} signature kept for binary compatibility; TLS peer verification stays enabled. */ + public LlmConfig( + String provider, + String model, + String apiKey, + String baseUrl, + int historyWindow, + @Nullable Integer maxTokens, + @Nullable Double temperature, + @Nullable Double timeoutSeconds, + int sessionMaxTotal, + int sessionMaxPerProvider, + boolean disableSystemProxy, + @Nullable String reasoningEffort, + int maxAttempts, + List parseErrors) { + this( + provider, + model, + apiKey, + baseUrl, + historyWindow, + maxTokens, + temperature, + timeoutSeconds, + sessionMaxTotal, + sessionMaxPerProvider, + disableSystemProxy, + true, + false, + reasoningEffort, + maxAttempts, + parseErrors); + } /** - * Builds one LLM config from raw `.env` values. + * 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), ""), - 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)); + parseInt( + values.get(A2ATConfigKeys.Llm.HISTORY_WINDOW), + A2ATConfigKeys.Llm.HISTORY_WINDOW, + DEFAULT_HISTORY_WINDOW, + parseErrors), + parseOptionalNumeric( + values.get(A2ATConfigKeys.Llm.MAX_TOKENS), + A2ATConfigKeys.Llm.MAX_TOKENS, + parseErrors, + Integer::parseInt), + parseOptionalNumeric( + values.get(A2ATConfigKeys.Llm.TEMPERATURE), + A2ATConfigKeys.Llm.TEMPERATURE, + parseErrors, + Double::parseDouble), + parseOptionalNumeric( + values.get(A2ATConfigKeys.Llm.TIMEOUT_SECONDS), + A2ATConfigKeys.Llm.TIMEOUT_SECONDS, + parseErrors, + Double::parseDouble), + parseInt( + values.get(A2ATConfigKeys.Llm.SESSION_MAX_TOTAL), + A2ATConfigKeys.Llm.SESSION_MAX_TOTAL, + DEFAULT_SESSION_MAX_TOTAL, + parseErrors), + parseInt( + values.get(A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER), + A2ATConfigKeys.Llm.SESSION_MAX_PER_PROVIDER, + DEFAULT_SESSION_MAX_PER_PROVIDER, + parseErrors), + parseBoolean( + values.get(A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY), + A2ATConfigKeys.Llm.DISABLE_SYSTEM_PROXY, + false, + parseErrors), + parseBoolean( + values.get(A2ATConfigKeys.Llm.SSL_VERIFY), A2ATConfigKeys.Llm.SSL_VERIFY, true, parseErrors), + parseBoolean( + values.get(A2ATConfigKeys.Llm.DETAIL_LOG_ENABLED), + A2ATConfigKeys.Llm.DETAIL_LOG_ENABLED, + false, + parseErrors), + parseReasoningEffort(values.get(A2ATConfigKeys.Llm.REASONING_EFFORT)), + parseMaxAttempts(values.get(A2ATConfigKeys.Llm.MAX_ATTEMPTS)), + parseErrors); + } + + 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(); } } 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 new file mode 100644 index 00000000..da0c634d --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/MetadataContent.java @@ -0,0 +1,66 @@ +package net.openan.a2at.sdk.core.model; + +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. + * + * @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, + @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 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}, {@code maxRounds}, and + * {@code performative} fields, in that order, the performative holding its upper-case name. Non-negotiation + * messages omit the {@code negotiationContext} key entirely. Repeated calls return equal maps. + * + * @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<>(); + 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()); + context.put("performative", negotiationContext.performative().name()); + metadata.put(NEGOTIATION_CONTEXT_METADATA_KEY, context); + } + return metadata; + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/NegotiationContext.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/NegotiationContext.java new file mode 100644 index 00000000..3b2e850b --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/NegotiationContext.java @@ -0,0 +1,92 @@ +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. + * + *

The {@code performative} carries the communicative intent of the message the context travels with. On an incoming + * message it is the performative of that message; the generation pipeline stamps the performative of the operation it + * performs on the output context, mirroring the nested {@code negotiationContext} structure of the A2A-T Negotiation-T + * specification. + * + * @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 + * @param performative communicative intent of the message this context travels with + * @since 2026-08 + */ +public record NegotiationContext(String id, int round, int maxRounds, NegotiationPerformative performative) { + + /** 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, maxRounds is below 1, or the + * performative is null + */ + 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 + "."); + } + if (performative == null) { + throw new IllegalArgumentException("Negotiation context performative must not be null."); + } + } + + /** + * Creates a context using the default round budget. + * + * @param id unique identifier of the negotiation session + * @param round current negotiation round, 1-based + * @param performative communicative intent of the message this context travels with + * @return new negotiation context with {@link #DEFAULT_MAX_ROUNDS} as the round budget + */ + public static NegotiationContext of(String id, int round, NegotiationPerformative performative) { + return new NegotiationContext(id, round, DEFAULT_MAX_ROUNDS, performative); + } + + /** + * Returns the context for the next round, leaving this context unchanged. + * + *

The returned context keeps this context's performative: the performative of an outbound message is decided by + * the generation pipeline, which stamps the operation's performative on the context it emits. + * + * @return new negotiation context whose round is one greater than the current round + */ + public NegotiationContext nextRound() { + return new NegotiationContext(id, round + 1, maxRounds, performative); + } + + /** + * Returns a copy of this context stamped with the given performative, leaving this context unchanged. + * + * @param performative communicative intent to stamp on the copy + * @return new negotiation context with the same {@code id}, {@code round}, and {@code maxRounds} as this context + */ + public NegotiationContext withPerformative(NegotiationPerformative performative) { + return new NegotiationContext(id, round, maxRounds, performative); + } + + /** + * 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-core/src/main/java/net/openan/a2at/sdk/core/model/NegotiationPerformative.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/NegotiationPerformative.java new file mode 100644 index 00000000..62b1e2df --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/NegotiationPerformative.java @@ -0,0 +1,49 @@ +package net.openan.a2at.sdk.core.model; + +import java.util.Optional; + +/** + * Performative (negotiation primitive) of a negotiation message as defined by the A2A-T Negotiation-T specification. + * + *

A performative expresses the communicative intent of a message within a negotiation session: {@code PROPOSE} + * puts a proposal on the table, {@code ACCEPT} and {@code REJECT} respond to a proposal, and {@code ABORT} + * terminates the session. Pervasives are online semantics only; how a performative maps to a prompt template is a + * concern of the negotiation layer, not of this enum. + * + * @since 2026-08 + */ +public enum NegotiationPerformative { + /** Puts a proposal on the table for the counterpart to evaluate. */ + PROPOSE, + + /** Accepts the counterpart's latest proposal. */ + ACCEPT, + + /** Rejects the counterpart's latest proposal. */ + REJECT, + + /** Terminates the negotiation session before an agreement is reached. */ + ABORT; + + /** + * Parses the wire representation of a performative. + * + *

Only the exact upper-case names of the four constants ({@code PROPOSE}, {@code ACCEPT}, {@code REJECT}, + * {@code ABORT}) are accepted; lower-case, {@code null}, and unknown values yield an empty result rather than an + * exception. + * + * @param value candidate wire value, may be {@code null} + * @return the matching performative, or {@link Optional#empty()} if the value is not an exact match + */ + public static Optional tryParse(String value) { + if (value == null) { + return Optional.empty(); + } + for (NegotiationPerformative performative : values()) { + if (performative.name().equals(value)) { + return Optional.of(performative); + } + } + return Optional.empty(); + } +} 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..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 @@ -1,7 +1,9 @@ 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; +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, SdkException error) { +public record OperationResult(@Nullable T value, @Nullable A2ATError error) { /** * Creates a successful operation result. @@ -18,7 +20,7 @@ public record OperationResult(T value, SdkException 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(SdkException 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/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 new file mode 100644 index 00000000..483fd733 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/PromptTemplate.java @@ -0,0 +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"; +} 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 new file mode 100644 index 00000000..8aa71290 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/SlotValidationError.java @@ -0,0 +1,35 @@ +package net.openan.a2at.sdk.core.model; + +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** + * Structured validation error details for one named slot. + * + *

Instances are carried inside parameter-extraction failures so callers can inspect which slot failed, under which + * error code, and why, without parsing exception messages. The {@code message} is rendered by the SDK from the code's + * message template; {@code facts} carries the structured fact values that produced the message. + * + * @param slotName name of the slot the error refers to + * @param code machine-readable error code for the error + * @param message human-readable explanation of the error + * @param facts fact values keyed by fact parameter name, may be null + * @since 2026-08 + */ +public record SlotValidationError(String slotName, String code, String message, @Nullable Map facts) { + + public SlotValidationError { + facts = facts == null ? null : Map.copyOf(facts); + } + + /** + * Creates a slot validation error without structured facts. + * + * @param slotName name of the slot the error refers to + * @param code machine-readable error code for the error + * @param message human-readable explanation of the error + */ + public SlotValidationError(String slotName, String code, String message) { + this(slotName, code, message, null); + } +} 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 new file mode 100644 index 00000000..5f339850 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/StandardTemplates.java @@ -0,0 +1,186 @@ +package net.openan.a2at.sdk.core.model; + +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 template is published in two spellings: a language-neutral {@link TemplateUri} and its raw {@code uri()} + * string (constant name suffixed {@code _URI}). The typed form is the identity representation used for template + * comparisons (for example against {@link PromptTemplate#templateUri()}) and by internal seams; the string form is + * what the {@code A2ATClient}/{@code A2ATServer} facades take as their {@code templateUri} parameter. The language is + * global prompt runtime context and is bound by the SDK, not by the caller. Use these constants instead of hand-written + * URI strings to keep the spelling centralized — a consistency test pins the two forms together. + * + *

Example: {@code StandardTemplates.ENERGY_SAVING_URI} is {@code Task-T/network-layer/ran-energy-saving/v1}. The + * Task-T and Notification-T templates carry the {@code network-layer} domain segment; Authorization-T and + * Negotiation-T templates do not. + * + * @since 2026-08 + */ +public final class StandardTemplates { + + 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"; + + /** 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 ran-energy-saving scenario. */ + public static final TemplateUri ENERGY_SAVING = + TemplateUri.of(TASK_EXTENSION_NAME, NETWORK_LAYER_SEGMENT, "ran-energy-saving"); + + /** Raw template URI of {@link #ENERGY_SAVING}: {@code Task-T/network-layer/ran-energy-saving/v1}. */ + public static final String ENERGY_SAVING_URI = ENERGY_SAVING.uri(); + + /** Task-T template for the private-line-complaint scenario. */ + public static final TemplateUri PRIVATE_LINE_COMPLAINT = + TemplateUri.of(TASK_EXTENSION_NAME, NETWORK_LAYER_SEGMENT, "private-line-complaint"); + + /** Raw template URI of {@link #PRIVATE_LINE_COMPLAINT}: {@code Task-T/network-layer/private-line-complaint/v1}. */ + public static final String PRIVATE_LINE_COMPLAINT_URI = PRIVATE_LINE_COMPLAINT.uri(); + + /** Notification-T template for the subscribe-incident scenario. */ + public static final TemplateUri SUBSCRIBE_INCIDENT = + TemplateUri.of(NOTIFICATION_EXTENSION_NAME, NETWORK_LAYER_SEGMENT, "subscribe-incident"); + + /** Raw template URI of {@link #SUBSCRIBE_INCIDENT}: {@code Notification-T/network-layer/subscribe-incident/v1}. */ + public static final String SUBSCRIBE_INCIDENT_URI = SUBSCRIBE_INCIDENT.uri(); + + /** Notification-T template for the service-recovery scenario. */ + public static final TemplateUri SERVICE_RECOVERY = + TemplateUri.of(NOTIFICATION_EXTENSION_NAME, NETWORK_LAYER_SEGMENT, "service-recovery"); + + /** Raw template URI of {@link #SERVICE_RECOVERY}: {@code Notification-T/network-layer/service-recovery/v1}. */ + public static final String SERVICE_RECOVERY_URI = SERVICE_RECOVERY.uri(); + + /** Authorization-T template for the authorization-policy-management scenario. */ + public static final TemplateUri AUTHORIZATION_POLICY_MANAGEMENT = + TemplateUri.of(AUTHORIZATION_EXTENSION_NAME, "authorization-policy-management"); + + /** + * Raw template URI of {@link #AUTHORIZATION_POLICY_MANAGEMENT}: + * {@code Authorization-T/authorization-policy-management/v1}. + */ + public static final String AUTHORIZATION_POLICY_MANAGEMENT_URI = AUTHORIZATION_POLICY_MANAGEMENT.uri(); + + /** Negotiation-T propose template for information negotiation. */ + public static final TemplateUri INFORMATION_NEGOTIATION_PROPOSE = + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "information-negotiation", "propose"); + + /** + * Raw template URI of {@link #INFORMATION_NEGOTIATION_PROPOSE}: + * {@code Negotiation-T/information-negotiation/propose/v1}. + */ + public static final String INFORMATION_NEGOTIATION_PROPOSE_URI = INFORMATION_NEGOTIATION_PROPOSE.uri(); + + /** Negotiation-T accept-reject template for information negotiation. */ + public static final TemplateUri INFORMATION_NEGOTIATION_ACCEPT_REJECT = + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "information-negotiation", "accept-reject"); + + /** + * Raw template URI of {@link #INFORMATION_NEGOTIATION_ACCEPT_REJECT}: + * {@code Negotiation-T/information-negotiation/accept-reject/v1}. + */ + public static final String INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI = + INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(); + + /** Negotiation-T propose template for target negotiation. */ + public static final TemplateUri TARGET_NEGOTIATION_PROPOSE = + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "target-negotiation", "propose"); + + /** Raw template URI of {@link #TARGET_NEGOTIATION_PROPOSE}: {@code Negotiation-T/target-negotiation/propose/v1}. */ + public static final String TARGET_NEGOTIATION_PROPOSE_URI = TARGET_NEGOTIATION_PROPOSE.uri(); + + /** Negotiation-T accept-reject template for target negotiation. */ + public static final TemplateUri TARGET_NEGOTIATION_ACCEPT_REJECT = + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "target-negotiation", "accept-reject"); + + /** + * Raw template URI of {@link #TARGET_NEGOTIATION_ACCEPT_REJECT}: + * {@code Negotiation-T/target-negotiation/accept-reject/v1}. + */ + public static final String TARGET_NEGOTIATION_ACCEPT_REJECT_URI = TARGET_NEGOTIATION_ACCEPT_REJECT.uri(); + + /** Negotiation-T propose template for feasibility negotiation. */ + public static final TemplateUri FEASIBILITY_NEGOTIATION_PROPOSE = + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "feasibility-negotiation", "propose"); + + /** + * Raw template URI of {@link #FEASIBILITY_NEGOTIATION_PROPOSE}: + * {@code Negotiation-T/feasibility-negotiation/propose/v1}. + */ + public static final String FEASIBILITY_NEGOTIATION_PROPOSE_URI = FEASIBILITY_NEGOTIATION_PROPOSE.uri(); + + /** Negotiation-T accept-reject template for feasibility negotiation. */ + public static final TemplateUri FEASIBILITY_NEGOTIATION_ACCEPT_REJECT = + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "feasibility-negotiation", "accept-reject"); + + /** + * Raw template URI of {@link #FEASIBILITY_NEGOTIATION_ACCEPT_REJECT}: + * {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}. + */ + public static final String FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI = + FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(); + + /** Negotiation-T common abort template. */ + public static final TemplateUri NEGOTIATION_ABORT = + TemplateUri.of(NEGOTIATION_EXTENSION_NAME, "common", "abort"); + + /** Raw template URI of {@link #NEGOTIATION_ABORT}: {@code Negotiation-T/common/abort/v1}. */ + public static final String NEGOTIATION_ABORT_URI = NEGOTIATION_ABORT.uri(); + + /** All built-in Task-T templates. */ + public static final List TASK = List.of(ENERGY_SAVING, PRIVATE_LINE_COMPLAINT); + + /** Raw template URIs of all built-in Task-T templates. */ + public static final List TASK_URIS = List.of(ENERGY_SAVING_URI, PRIVATE_LINE_COMPLAINT_URI); + + /** All built-in Notification-T templates. */ + public static final List NOTIFICATION = List.of(SUBSCRIBE_INCIDENT, SERVICE_RECOVERY); + + /** Raw template URIs of all built-in Notification-T templates. */ + public static final List NOTIFICATION_URIS = List.of(SUBSCRIBE_INCIDENT_URI, SERVICE_RECOVERY_URI); + + /** All built-in Authorization-T templates. */ + public static final List AUTHORIZATION = List.of(AUTHORIZATION_POLICY_MANAGEMENT); + + /** Raw template URIs of all built-in Authorization-T templates. */ + public static final List AUTHORIZATION_URIS = List.of(AUTHORIZATION_POLICY_MANAGEMENT_URI); + + /** All built-in Negotiation-T templates. */ + public static final List NEGOTIATION = List.of( + INFORMATION_NEGOTIATION_PROPOSE, + INFORMATION_NEGOTIATION_ACCEPT_REJECT, + TARGET_NEGOTIATION_PROPOSE, + TARGET_NEGOTIATION_ACCEPT_REJECT, + FEASIBILITY_NEGOTIATION_PROPOSE, + FEASIBILITY_NEGOTIATION_ACCEPT_REJECT, + NEGOTIATION_ABORT); + + /** Raw template URIs of all built-in Negotiation-T templates. */ + public static final List NEGOTIATION_URIS = List.of( + INFORMATION_NEGOTIATION_PROPOSE_URI, + INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, + TARGET_NEGOTIATION_PROPOSE_URI, + TARGET_NEGOTIATION_ACCEPT_REJECT_URI, + FEASIBILITY_NEGOTIATION_PROPOSE_URI, + FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, + NEGOTIATION_ABORT_URI); +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/TemplateUri.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/TemplateUri.java new file mode 100644 index 00000000..d7113d4d --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/model/TemplateUri.java @@ -0,0 +1,133 @@ +package net.openan.a2at.sdk.core.model; + +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; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * 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}), 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 pathSegments middle URI segments, such as {@code [network-layer, ran-energy-saving]} or + * {@code [information-negotiation, propose]} + * @param templateVersion trailing URI segment, such as {@code v1} + * @since 2026-08 + */ +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"; + + /** + * Validates the components and defensively copies the path segment list. + * + * @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"); + 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 : pathSegments) { + validateSegment(segment, "Template URI path segment"); + } + validateSegment(templateVersion, "Template version"); + pathSegments = List.copyOf(pathSegments); + } + + /** + * 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 pathSegments middle URI segments, such as {@code network-layer, ran-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, ran-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 List pathSegments, + @NonNull String templateVersion) { + return new TemplateUri(extensionName, pathSegments, templateVersion); + } + + /** + * Tries to parse a raw template URI into its components. + * + * @param templateUri template URI such as {@code Task-T/network-layer/ran-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 + */ + public static Optional parse(@Nullable 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], + 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/information-negotiation/propose/v1} + */ + public @NonNull String uri() { + return Stream.concat( + Stream.concat(Stream.of(extensionName), pathSegments.stream()), + Stream.of(templateVersion)) + .collect(Collectors.joining("/")); + } + + 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/main/java/net/openan/a2at/sdk/core/resources/ClasspathResourceDirectories.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/ClasspathResourceDirectories.java new file mode 100644 index 00000000..1f81a55f --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/ClasspathResourceDirectories.java @@ -0,0 +1,87 @@ +package net.openan.a2at.sdk.core.resources; + +import java.io.IOException; +import java.net.JarURLConnection; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Enumeration; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.jar.JarFile; + +/** + * Lists the first-level directory names of a classpath resource directory. + * + *

The enumeration covers every classpath root carrying the directory, both {@code file://} roots and {@code jar://} + * entries, so extension directories bundled in any jar on the classpath are discovered. Callers use this to avoid + * hardcoding resource-type lists that drift from the packaged resource tree. + * + * @since 2026-08 + */ +public final class ClasspathResourceDirectories { + + private ClasspathResourceDirectories() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** + * Lists the distinct first-level directory names of one classpath resource directory. + * + * @param resourceDirectory classpath directory path such as {@code prompt_resources/templates/} + * @return distinct directory names in encounter order; empty when the directory exists on no classpath root + * @throws IOException when a classpath root cannot be read + */ + public static List list(String resourceDirectory) throws IOException { + Set names = new LinkedHashSet<>(); + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + if (classLoader == null) { + classLoader = ClasspathResourceDirectories.class.getClassLoader(); + } + Enumeration roots = classLoader.getResources(resourceDirectory); + while (roots.hasMoreElements()) { + URL root = roots.nextElement(); + if ("file".equals(root.getProtocol())) { + collectFilesystemNames(root, names); + } else if ("jar".equals(root.getProtocol())) { + collectJarNames((JarURLConnection) root.openConnection(), resourceDirectory, names); + } + } + return List.copyOf(names); + } + + private static void collectFilesystemNames(URL root, Set names) throws IOException { + Path rootDirectory; + try { + rootDirectory = Path.of(root.toURI()); + } catch (URISyntaxException exception) { + throw new IOException("Malformed classpath root URL: " + root, exception); + } + try (var entries = Files.list(rootDirectory)) { + entries.filter(Files::isDirectory).forEach(path -> names.add(path.getFileName().toString())); + } + } + + private static void collectJarNames(JarURLConnection connection, String resourceDirectory, Set names) + throws IOException { + String prefix = resourceDirectory.endsWith("/") ? resourceDirectory : resourceDirectory + "/"; + try (JarFile jarFile = connection.getJarFile()) { + jarFile.stream() + .filter(entry -> !entry.isDirectory()) + .map(entry -> firstSegmentAfter(entry.getName(), prefix)) + .filter(name -> !name.isEmpty()) + .forEach(names::add); + } + } + + private static String firstSegmentAfter(String entryName, String prefix) { + if (!entryName.startsWith(prefix)) { + return ""; + } + String remainder = entryName.substring(prefix.length()); + int slash = remainder.indexOf('/'); + return slash < 0 ? remainder : remainder.substring(0, slash); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/ClasspathResourceStreams.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/ClasspathResourceStreams.java new file mode 100644 index 00000000..4ce8de4f --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/ClasspathResourceStreams.java @@ -0,0 +1,36 @@ +package net.openan.a2at.sdk.core.resources; + +import java.io.InputStream; + +/** + * Opens classpath resources through the shared classloader fallback chain. + * + *

The context classloader of the current thread is consulted first; when it has no resource of the given path, + * the classloader of this utility class is consulted as a fallback. A caller that finds no stream handles the + * absence with its own domain exception, so this utility only returns {@code null}. + * + * @since 2026-08 + */ +public final class ClasspathResourceStreams { + + private ClasspathResourceStreams() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** + * Opens one classpath resource through the context classloader with a fallback to this class's classloader. + * + * @param resourcePath slash-separated classpath resource path + * @return open stream of the resource, or {@code null} when no classloader provides it + */ + public static InputStream open(String resourcePath) { + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + if (contextClassLoader != null) { + InputStream stream = contextClassLoader.getResourceAsStream(resourcePath); + if (stream != null) { + return stream; + } + } + return ClasspathResourceStreams.class.getClassLoader().getResourceAsStream(resourcePath); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java new file mode 100644 index 00000000..efa7ef2c --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/resources/PathSegments.java @@ -0,0 +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 + "."); + } + } + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryHook.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryHook.java new file mode 100644 index 00000000..14c37699 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryHook.java @@ -0,0 +1,24 @@ +package net.openan.a2at.sdk.core.telemetry; + +/** + * Telemetry SPI consumed by SDK facades. Implementations live in optional observability modules; the default + * {@link #NO_OP} keeps the core dependency-free. + */ +public interface TelemetryHook { + + /** Starts one facade-operation observation named {@code a2at.sdk..}. */ + TelemetryOperation startSdkOperation(String role, String method); + + /** Reports one terminal negotiation round count (outcome: accept/reject). */ + void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds); + + TelemetryHook NO_OP = new TelemetryHook() { + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + return TelemetryOperation.NO_OP; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) {} + }; +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryOperation.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryOperation.java new file mode 100644 index 00000000..16465de1 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryOperation.java @@ -0,0 +1,32 @@ +package net.openan.a2at.sdk.core.telemetry; + +/** One observed SDK operation; AutoCloseable for try-with-resources. */ +public interface TelemetryOperation extends AutoCloseable { + + /** Attaches one attribute to the underlying observation. No-op values are ignored. */ + void setAttribute(String key, Object value); + + /** Marks the operation as business-successful before close. */ + void markSuccess(); + + /** Marks the operation as failed; the Throwable is recorded verbatim. */ + void markError(Throwable error); + + /** Ends the operation. Idempotent. */ + @Override + void close(); + + TelemetryOperation NO_OP = new TelemetryOperation() { + @Override + public void setAttribute(String key, Object value) {} + + @Override + public void markSuccess() {} + + @Override + public void markError(Throwable error) {} + + @Override + public void close() {} + }; +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/package-info.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/package-info.java new file mode 100644 index 00000000..f4325776 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/package-info.java @@ -0,0 +1,5 @@ +/** Telemetry SPI for the a2a-t Java SDK core. */ +@NullMarked +package net.openan.a2at.sdk.core.telemetry; + +import org.jspecify.annotations.NullMarked; diff --git a/a2a-t-core/src/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 new file mode 100644 index 00000000..8bb4d8ed --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidationException.java @@ -0,0 +1,105 @@ +package net.openan.a2at.sdk.core.validation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.model.SlotValidationError; + +/** + * Failure raised by the content validation pipeline. + * + *

Carries the machine-readable error code inherited from {@link A2ATBusinessException} and optionally structured + * per-slot validation error details so callers can inspect which slot failed, under which error code, and why, without + * parsing exception messages. + * + * @since 2026-08 + */ +public class ContentValidationException extends A2ATBusinessException { + + private final List errors; + + private final Map params; + + /** + * Creates a content validation failure with an error code and one message. + * + * @param code machine-readable error code for the failure + * @param message failure message + */ + public ContentValidationException(String code, String message) { + this(code, message, List.of(), Map.of(), null); + } + + /** + * Creates a content validation failure with an error code, one message and slot details. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + */ + public ContentValidationException(String code, String message, List errors) { + this(code, message, errors, Map.of(), null); + } + + /** + * Creates a content validation failure with an error code, one message and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param cause root cause + */ + public ContentValidationException(String code, String message, Throwable cause) { + this(code, message, List.of(), Map.of(), cause); + } + + public ContentValidationException(String code, String message, List errors, Throwable cause) { + this(code, message, errors, Map.of(), cause); + } + + /** + * Creates a content validation failure with an error code, one message, slot details, partial extraction params and + * a root cause. + * + *

{@code params} carries the extraction result produced before rejection; the semantic validator deliberately + * emits {@code null} values for slots it could not extract, so both the map and its values may be {@code null} and + * must be accepted defensively. The stored copies are unmodifiable and preserve the original ordering. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + * @param params partial extraction params, may carry {@code null} values + * @param cause root cause + */ + public ContentValidationException( + String code, + String message, + List errors, + Map params, + Throwable cause) { + super(code, message, null, cause); + this.errors = errors == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(errors)); + this.params = params == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(params)); + } + + /** + * Returns the structured per-slot validation error details. + * + * @return immutable list of slot validation errors, never null + */ + public List errors() { + return errors; + } + + /** + * Returns the partial extraction params captured before rejection. + * + * @return unmodifiable map preserving insertion order, never null; values may be {@code null} for slots the + * semantic validator could not extract + */ + public Map params() { + return params; + } +} 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 new file mode 100644 index 00000000..c9304fac --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ContentValidator.java @@ -0,0 +1,32 @@ +package net.openan.a2at.sdk.core.validation; + +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.jspecify.annotations.NonNull; + +/** + * Entry point for validating content and extracting filled parameters. + * + *

Implementations orchestrate the full validation pipeline: input validation, rule-level gate, semantic validation + * and parameter merging. + * + * @since 2026-08 + */ +public interface ContentValidator { + + /** + * Validates one content prompt and extracts its filled parameters. + * + * @param prompt content prompt text + * @param schema caller-provided parameter JSON schema + * @param templateUri URI of the template the content is validated against, such as + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return filled parameter data carrying the merged parameters + * @throws ContentValidationException with {@code negotiation.invalid_input} if the template URI is null or + * malformed, the prompt is null or blank, or the schema is null + * @throws ContentValidationException if the validation fails at any stage + */ + FilledParamData validate( + @NonNull String prompt, @NonNull Map schema, @NonNull TemplateUri templateUri); +} 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 new file mode 100644 index 00000000..c3074105 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RetryUtil.java @@ -0,0 +1,73 @@ +package net.openan.a2at.sdk.core.validation; + +import java.util.function.Supplier; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Lightweight retry utility for the content validation pipeline. + * + *

Only retries on {@link ContentValidationException} failures carrying an {@code llm.*} catalog code, such as + * {@link ErrorCatalog#LLM_INVOCATION_FAILED} and {@link ErrorCatalog#LLM_RESPONSE_INVALID}. All other failure codes and + * unknown runtime exceptions are rethrown immediately. + * + * @since 2026-08 + */ +final class RetryUtil { + + private static final Logger LOGGER = LoggerFactory.getLogger(RetryUtil.class); + + private static final String LLM_CODE_DOMAIN = "llm."; + + private RetryUtil() {} + + /** + * Executes one action with retry on LLM failures. + * + * @param result type + * @param maxAttempts maximum number of attempts, must be at least 1 + * @param stepName name of the step for logging purposes + * @param action the action to execute + * @return the result of the action + * @throws ContentValidationException if the action fails on every attempt with a retryable error, or if it fails + * with a non-retryable error + */ + public static T withRetry(int maxAttempts, String stepName, Supplier action) { + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be at least 1"); + } + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return action.get(); + } catch (ContentValidationException exception) { + if (!isRetryableLlmFailure(exception)) { + throw exception; + } + if (attempt == maxAttempts) { + LOGGER.atWarn() + .log( + "{}_retry_exhausted attempts={} last_error={}", + stepName, + attempt, + exception.getMessage()); + throw exception; + } + LOGGER.atWarn() + .log( + "{}_retry_attempt attempt={}/{} error={}", + stepName, + attempt, + maxAttempts, + exception.getMessage()); + } catch (RuntimeException exception) { + throw exception; + } + } + throw new IllegalStateException("unreachable"); + } + + private static boolean isRetryableLlmFailure(ContentValidationException exception) { + return exception.getCode().startsWith(LLM_CODE_DOMAIN); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RuleChecker.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RuleChecker.java new file mode 100644 index 00000000..fb6a41e3 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/RuleChecker.java @@ -0,0 +1,23 @@ +package net.openan.a2at.sdk.core.validation; + +import java.util.Map; + +/** + * Deterministic rule-level checker for content. + * + *

The checker is deterministic and never calls an LLM. It validates structural constraints of the content and + * returns context parameters parsed from it. + * + * @since 2026-08 + */ +public interface RuleChecker { + + /** + * Runs the rule-level check of one content prompt. + * + * @param prompt content prompt text + * @return context parameters parsed from the content + * @throws ContentValidationException if the content violates a structural rule + */ + Map check(String prompt); +} \ No newline at end of file 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 new file mode 100644 index 00000000..b116e0d8 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/SemanticValidator.java @@ -0,0 +1,32 @@ +package net.openan.a2at.sdk.core.validation; + +import java.util.Map; +import org.jspecify.annotations.NonNull; +import net.openan.a2at.sdk.core.model.TemplateUri; + +/** + * 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 { + + /** + * 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 addressing value the content is validated against + * @param templateContent loaded template text used as a reference for structure/completeness checks + * @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 + */ + @NonNull ValidationResult validate( + @NonNull String prompt, @NonNull Map schema, @NonNull T reference, @NonNull String templateContent); +} 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..a3cfb446 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/TemplateContentLoader.java @@ -0,0 +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); +} 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 new file mode 100644 index 00000000..27c356e6 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationPipeline.java @@ -0,0 +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); + } +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationResult.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationResult.java new file mode 100644 index 00000000..92ecb3bd --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/ValidationResult.java @@ -0,0 +1,15 @@ +package net.openan.a2at.sdk.core.validation; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.SlotValidationError; + +/** + * Outcome of a semantic validation step in the content validation pipeline. + * + * @param verdict overall semantic verdict; {@code true} only when every semantic constraint holds + * @param errors structured semantic errors; empty when the verdict is true + * @param params parameters extracted from the content per the caller-provided schema + * @since 2026-08 + */ +public record ValidationResult(boolean verdict, List errors, Map params) {} \ No newline at end of file diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/package-info.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/package-info.java new file mode 100644 index 00000000..00b92896 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/validation/package-info.java @@ -0,0 +1,2 @@ +/** Content validation abstractions, interfaces and pipeline orchestration. */ +package net.openan.a2at.sdk.core.validation; \ No newline at end of file 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 new file mode 100644 index 00000000..4e0ee42e --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorCodesTest.java @@ -0,0 +1,76 @@ +package net.openan.a2at.sdk.core.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Map; +import java.util.TreeMap; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the deprecated {@link A2ATErrorCodes} forwarding constants. + * + *

Tests cover the following scenarios: + * + *

    + *
  • Every deprecated constant forwards to the layered {@code domain.semantic} code of its replacement + * {@link ErrorCatalog} entry + *
+ * + * @since 2026-06 + */ +@SuppressWarnings("removal") +class A2ATErrorCodesTest { + + /** + * Verifies that {@link A2ATErrorCodes} forwards exactly the twenty-one legacy constants to their catalog + * replacements. + * + *

Scenario: Reflection inspects all declared static final String fields of the deprecated registry. Expected + * result: Each field value equals the code of the catalog entry that replaced it. + * + * @throws IllegalAccessException if a declared field cannot be read + */ + @Test + void should_forwardEveryConstantToItsCatalogReplacement_When_reflectingOverDeclaredFields() + throws IllegalAccessException { + Map constants = new TreeMap<>(); + for (Field field : A2ATErrorCodes.class.getDeclaredFields()) { + if (field.getType() == String.class + && Modifier.isStatic(field.getModifiers()) + && Modifier.isFinal(field.getModifiers())) { + field.setAccessible(true); + constants.put(field.getName(), (String) field.get(null)); + } + } + + assertEquals( + Map.ofEntries( + Map.entry("SDK_INTERNAL_ERROR", ErrorCatalog.INFRA_INTERNAL_ERROR.getCode()), + Map.entry("PARAM_EXTRACTION_FAILED", ErrorCatalog.SLOT_NOT_PROVIDED.getCode()), + Map.entry("INPUT_TEXT_TOO_LONG", ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode()), + Map.entry("TEMPLATE_NOT_FOUND", ErrorCatalog.TEMPLATE_NOT_FOUND.getCode()), + Map.entry( + "NEGOTIATION_CONTENT_EXTRACT_FAILED", + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode()), + Map.entry( + "NEGOTIATION_SEMANTIC_REJECTED", ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode()), + Map.entry("NEGOTIATION_RULE_VIOLATION", ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode()), + Map.entry("NEGOTIATION_SLOT_MISSING", ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode()), + Map.entry("NEGOTIATION_INVALID_INPUT", ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode()), + Map.entry("NEGOTIATION_LLM_INFRASTRUCTURE_ERROR", ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), + Map.entry("VALIDATION_INVALID_INPUT", ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode()), + Map.entry("VALIDATION_RULE_VIOLATION", ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode()), + Map.entry("VALIDATION_SEMANTIC_REJECTED", ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode()), + Map.entry("VALIDATION_LLM_INFRASTRUCTURE_ERROR", ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), + Map.entry("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", ErrorCatalog.TEMPLATE_NOT_FOUND.getCode()), + Map.entry("PROMPT_RESOURCE_LOAD_ERROR", ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode()), + Map.entry("SLOT_SCHEMA_NOT_FOUND", ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode()), + Map.entry("LLM_INVOCATION_FAILED", ErrorCatalog.LLM_INVOCATION_FAILED.getCode()), + Map.entry("RENDER_FAILED", ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode()), + Map.entry("SLOT_VALIDATION_ERROR", ErrorCatalog.SLOT_RULE_VIOLATION.getCode()), + Map.entry("PROCESSED_PROMPT_PARSE_ERROR", ErrorCatalog.SCENARIO_NOT_MATCHED.getCode())), + constants); + } +} 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..fa8bb25b --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/exception/A2ATErrorTest.java @@ -0,0 +1,69 @@ +package net.openan.a2at.sdk.core.exception; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link A2ATError}. + * + *

Tests cover the following scenarios: + * + *

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

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

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

Scenario: Attempt to create A2A-T errors with a null code. Expected result: NullPointerException is thrown. + */ + @Test + void should_throwNullPointerException_When_createdWithNullCode() { + assertThrows(NullPointerException.class, () -> new A2ATError(null, "missing")); + assertThrows(NullPointerException.class, () -> new A2ATError(null, "missing", new IllegalStateException())); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParserTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/json/JacksonJsonValueParserTest.java index 5a550820..1491eda8 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 { @@ -13,17 +13,17 @@ class JacksonJsonValueParserTest { void parseObjectReturnsJsonObjectFields() { JsonValueParser parser = new JacksonJsonValueParser(); - Map parsed = parser.parseObject("{\"matched\":true,\"scenario_code\":\"energy_saving\"}"); + Map parsed = parser.parseObject("{\"matched\":true,\"scenario_code\":\"ran-energy-saving\"}"); assertEquals(true, parsed.get("matched")); - assertEquals("energy_saving", parsed.get("scenario_code")); + assertEquals("ran-energy-saving", parsed.get("scenario_code")); } @Test 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/A2ATConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java index b40d5bb4..05e699bb 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/A2ATConfigTest.java @@ -1,21 +1,22 @@ 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; -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
  • + *
  • Loading unified SDK configuration from .env files + *
  • Default values for missing configuration keys + *
  • Error handling for missing configuration files *
* * @since 2026-06 @@ -23,12 +24,12 @@ class A2ATConfigTest { /** - * Verifies that {@link A2ATConfig#load(Path)} correctly builds a unified configuration - * from a complete .env file with all keys specified. + * 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()}. + *

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 */ @@ -44,7 +45,7 @@ void should_buildUnifiedConfig_When_envFileContainsAllKeys() throws IOException A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=local_file A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=prompt_resources - A2AT_LLM_PROVIDER=openai_compatible + A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=gpt-4.1 A2AT_LLM_API_KEY=test-key A2AT_LLM_BASE_URL=https://api.openai.com/v1 @@ -62,7 +63,7 @@ void should_buildUnifiedConfig_When_envFileContainsAllKeys() throws IOException assertEquals("zh-CN", config.prompt().language()); assertEquals("local_file", config.prompt().sourceType()); assertEquals("prompt_resources", config.prompt().localRootDir()); - assertEquals("openai_compatible", config.llm().provider()); + assertEquals("openai", config.llm().provider()); assertEquals("gpt-4.1", config.llm().model()); assertEquals("test-key", config.llm().apiKey()); assertEquals("https://api.openai.com/v1", config.llm().baseUrl()); @@ -76,12 +77,10 @@ void should_buildUnifiedConfig_When_envFileContainsAllKeys() throws IOException } /** - * Verifies that {@link A2ATConfig#load(Path)} applies default values for missing - * configuration keys. + * 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, - * current directory root). + *

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 */ @@ -92,22 +91,22 @@ void should_useClasspathDefaults_When_promptKeysAreMissing() throws IOException Files.writeString( envFile, """ - A2AT_LLM_PROVIDER=local_rule + A2AT_LLM_PROVIDER=openai A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory """); A2ATConfig config = A2ATConfig.load(envFile); assertEquals("classpath", config.prompt().sourceType()); - assertEquals(".", config.prompt().localRootDir()); + assertNull(config.prompt().localRootDir()); } /** - * Verifies that {@link A2ATConfig#load(Path)} throws {@link ConfigFileNotFoundException} - * when the specified file does not exist. + * 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. + *

Scenario: Attempt to load configuration from a non-existent file path. Expected result: + * ConfigFileNotFoundException is thrown. */ @Test void should_throwConfigFileNotFoundException_When_envFileDoesNotExist() { @@ -117,24 +116,166 @@ void should_throwConfigFileNotFoundException_When_envFileDoesNotExist() { } /** - * Verifies that the repository's env.example file exists and optionally matches - * an upstream template. + * 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). + *

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(); + 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"), + 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/DotEnvConfigSourceTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/DotEnvConfigSourceTest.java index cca4b5fa..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; /** @@ -70,13 +70,13 @@ void should_skipCommentsAndEmptyLines_When_envFileContainsCommentsAndBlanks() th # This is a comment A2AT_LANGUAGE=en-US - A2AT_LLM_PROVIDER=openai_compatible + A2AT_LLM_PROVIDER=openai # Another comment """); Map values = DotEnvConfigSource.load(envFile); - assertEquals(Map.of("A2AT_LANGUAGE", "en-US", "A2AT_LLM_PROVIDER", "openai_compatible"), values); + assertEquals(Map.of("A2AT_LANGUAGE", "en-US", "A2AT_LLM_PROVIDER", "openai"), values); } /** @@ -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/ExtensionUriConstantsTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/ExtensionUriConstantsTest.java new file mode 100644 index 00000000..ad3dc975 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/ExtensionUriConstantsTest.java @@ -0,0 +1,46 @@ +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.lang.reflect.Constructor; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ExtensionUriConstants}. + * + *

Tests cover the extension URI constants and utility class invariants. + * + * @since 2026-08 + */ +class ExtensionUriConstantsTest { + + @Test + void should_haveCorrectTaskTExtensionUri() { + assertEquals( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1", + ExtensionUriConstants.TASK_T_EXTENSION_URI); + } + + @Test + void should_haveCorrectAuthorizationTExtensionUri() { + assertEquals( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1", + ExtensionUriConstants.AUTHORIZATION_T_EXTENSION_URI); + } + + @Test + void should_haveCorrectNotificationTExtensionUri() { + assertEquals( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1", + ExtensionUriConstants.NOTIFICATION_T_EXTENSION_URI); + } + + @Test + void should_havePrivateConstructor() throws Exception { + Constructor constructor = + ExtensionUriConstants.class.getDeclaredConstructor(); + constructor.setAccessible(true); + assertThrows(Exception.class, constructor::newInstance); + } +} \ No newline at end of file diff --git a/a2a-t-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..83260a8d --- /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(16384, 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), 16384); + + assertTrue(message.contains("13000")); + assertTrue(message.contains("16384")); + assertTrue(message.contains(A2ATConfigKeys.Input.MAX_TEXT_CHARS)); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigMaxAttemptsLoggingTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigMaxAttemptsLoggingTest.java new file mode 100644 index 00000000..23988487 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigMaxAttemptsLoggingTest.java @@ -0,0 +1,109 @@ +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * Unit tests for the warning logs emitted while parsing {@code A2AT_LLM_MAX_ATTEMPTS}. + * + *

The value semantics themselves (in-range pass-through, clamping to 1 and 10, fallback to the default of 3 for + * unparseable or blank values) are covered by {@link LlmConfigTest}; this suite locks the warning side of the contract: + * every clamp and every fallback emits one warning carrying the raw configured value and the resolved value, and + * well-formed values emit no warning at all. + */ +class LlmConfigMaxAttemptsLoggingTest { + + private final ListAppender appender = new ListAppender<>(); + + private final Logger logger = (Logger) LoggerFactory.getLogger(LlmConfig.class); + + @BeforeEach + void attachAppender() { + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + logger.detachAppender(appender); + } + + /** + * Verifies that a value below the allowed range clamps to 1 with one warning carrying the raw value and the clamped + * value. + */ + @Test + void should_warnWithRawValue_When_maxAttemptsIsBelowTheLowerBound() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "0")); + + assertEquals(1, config.maxAttempts()); + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertContains(warnings.get(0), "key=A2AT_LLM_MAX_ATTEMPTS", "raw_value=0", "clamped_value=1"); + } + + /** + * Verifies that a value above the allowed range clamps to 10 with one warning carrying the raw value and the + * clamped value. + */ + @Test + void should_warnWithRawValue_When_maxAttemptsIsAboveTheUpperBound() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "99")); + + assertEquals(10, config.maxAttempts()); + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertContains(warnings.get(0), "key=A2AT_LLM_MAX_ATTEMPTS", "raw_value=99", "clamped_value=10"); + } + + /** + * Verifies that an unparseable value falls back to the default of 3 with one warning carrying the raw value and the + * default value. + */ + @Test + void should_warnWithRawValue_When_maxAttemptsIsUnparseable() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "garbage")); + + assertEquals(3, config.maxAttempts()); + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertContains(warnings.get(0), "key=A2AT_LLM_MAX_ATTEMPTS", "raw_value=garbage", "default_value=3"); + } + + /** Verifies that in-range values and missing or blank values resolve without any warning. */ + @Test + void should_notWarn_When_maxAttemptsIsInRangeAbsentOrBlank() { + assertEquals(5, LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "5")).maxAttempts()); + assertEquals(1, LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "1")).maxAttempts()); + assertEquals( + 10, LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "10")).maxAttempts()); + assertEquals(3, LlmConfig.fromMap(Map.of()).maxAttempts()); + assertEquals(3, LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", " ")).maxAttempts()); + + assertTrue(warningMessages().isEmpty(), "well-formed or absent values must not emit a warning"); + } + + private List warningMessages() { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void assertContains(String message, String... fragments) { + for (String fragment : fragments) { + assertTrue(message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); + } + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java index 76cd2630..6ab6d588 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/LlmConfigTest.java @@ -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; @@ -9,9 +12,12 @@ * Unit tests for {@link LlmConfig}. * *

Tests cover the following scenarios: + * *

    - *
  • Default values when configuration keys are missing
  • - *
  • Overriding defaults with environment variable values
  • + *
  • Default values when configuration keys are missing + *
  • Overriding defaults with environment variable values + *
  • Max attempts parsing: in-range values, clamping, fallback, and blank values + *
  • Reasoning effort parsing: valid values, case normalization, blank values *
* * @since 2026-06 @@ -19,19 +25,12 @@ class LlmConfigTest { /** - * Verifies that {@link LlmConfig#fromMap(Map)} applies default values when - * no configuration keys are provided. + * 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_compatible" - * - model, apiKey, baseUrl: empty strings - * - historyWindow: 12 - * - maxTokens: 2048 - * - temperature: 0.2 - * - timeoutSeconds: 30.0 - * - sessionMaxTotal: 300 - * - sessionMaxPerProvider: 100 + *

Scenario: An empty map is passed to fromMap(). Expected result: All fields use predefined defaults: - + * provider: "openai" - model, apiKey, baseUrl: empty strings - historyWindow: 10 - maxTokens: null - temperature: + * null - timeoutSeconds: null - reasoningEffort: null - sessionMaxTotal: 300 - sessionMaxPerProvider: 100 - + * maxAttempts: 3 */ @Test void should_useDefaults_When_keysAreMissing() { @@ -39,38 +38,44 @@ void should_useDefaults_When_keysAreMissing() { LlmConfig config = LlmConfig.fromMap(values); - assertEquals("openai_compatible", config.provider()); + assertEquals("openai", config.provider()); assertEquals("", config.model()); assertEquals("", config.apiKey()); assertEquals("", config.baseUrl()); - 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()); + assertEquals(false, config.detailLogEnabled()); + assertNull(config.reasoningEffort()); assertEquals(300, config.sessionMaxTotal()); assertEquals(100, config.sessionMaxPerProvider()); + assertEquals(3, config.maxAttempts()); } /** - * Verifies that {@link LlmConfig#fromMap(Map)} overrides default values with - * values from the provided map. + * Verifies that {@link LlmConfig#fromMap(Map)} overrides default values with values from the provided map. * - *

Scenario: A map containing all LLM configuration keys with custom values. - * Expected result: All fields use the values from the map instead of defaults. + *

Scenario: A map containing all LLM configuration keys with custom values. Expected result: All fields use the + * values from the map instead of defaults. */ @Test void should_overrideDefaults_When_keysAreProvided() { - Map values = Map.of( - "A2AT_LLM_PROVIDER", "example_provider", - "A2AT_LLM_MODEL", "example-model", - "A2AT_LLM_API_KEY", "test-api-key", - "A2AT_LLM_BASE_URL", "https://llm.example.test/v1", - "A2AT_LLM_HISTORY_WINDOW", "20", - "A2AT_LLM_MAX_TOKENS", "4096", - "A2AT_LLM_TEMPERATURE", "0.5", - "A2AT_LLM_TIMEOUT_SECONDS", "60", - "A2AT_LLM_SESSION_MAX_TOTAL", "500", - "A2AT_LLM_SESSION_MAX_PER_PROVIDER", "150"); + Map values = Map.ofEntries( + Map.entry("A2AT_LLM_PROVIDER", "example_provider"), + Map.entry("A2AT_LLM_MODEL", "example-model"), + Map.entry("A2AT_LLM_API_KEY", "test-api-key"), + Map.entry("A2AT_LLM_BASE_URL", "https://llm.example.test/v1"), + Map.entry("A2AT_LLM_HISTORY_WINDOW", "20"), + Map.entry("A2AT_LLM_MAX_TOKENS", "4096"), + Map.entry("A2AT_LLM_TEMPERATURE", "0.5"), + Map.entry("A2AT_LLM_TIMEOUT_SECONDS", "60"), + Map.entry("A2AT_LLM_DISABLE_SYSTEM_PROXY", "true"), + Map.entry("A2AT_LLM_SESSION_MAX_TOTAL", "500"), + Map.entry("A2AT_LLM_SESSION_MAX_PER_PROVIDER", "150"), + Map.entry("A2AT_LLM_MAX_ATTEMPTS", "5"), + Map.entry("A2AT_LLM_REASONING_EFFORT", "HIGH")); LlmConfig config = LlmConfig.fromMap(values); @@ -82,7 +87,205 @@ 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()); + } + + /** + * Verifies that {@link LlmConfig#fromMap(Map)} clamps an out-of-range max attempts value to the nearest allowed + * bound. + * + *

Scenario: A2AT_LLM_MAX_ATTEMPTS is set to "0" (below the lower bound of 1) and to "99" (above the upper bound + * of 10). Expected result: maxAttempts resolves to 1 and to 10 respectively. + */ + @Test + void should_clampMaxAttempts_When_valueIsOutOfRange() { + LlmConfig tooSmall = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "0")); + LlmConfig tooLarge = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "99")); + + assertEquals(1, tooSmall.maxAttempts()); + assertEquals(10, tooLarge.maxAttempts()); + } + + /** + * Verifies that {@link LlmConfig#fromMap(Map)} falls back to the default max attempts value when the configured + * value is not a valid integer. + * + *

Scenario: A2AT_LLM_MAX_ATTEMPTS is set to a non-numeric value. Expected result: maxAttempts resolves to the + * default of 3. + */ + @Test + void should_fallBackToDefaultMaxAttempts_When_valueIsUnparseable() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "garbage")); + + assertEquals(3, config.maxAttempts()); + } + + /** + * Verifies that {@link LlmConfig#fromMap(Map)} uses the default max attempts value when the key is absent or blank. + * + *

Scenario: A2AT_LLM_MAX_ATTEMPTS is missing from the map, or set to a blank value. Expected result: maxAttempts + * resolves to the default of 3 in both cases. + */ + @Test + void should_useDefaultMaxAttempts_When_valueIsAbsentOrBlank() { + LlmConfig absent = LlmConfig.fromMap(Map.of()); + LlmConfig blank = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "")); + + assertEquals(3, absent.maxAttempts()); + assertEquals(3, blank.maxAttempts()); + } + + /** + * Verifies that {@link LlmConfig#fromMap(Map)} parses reasoning effort with case normalization. + * + *

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

Scenario: A2AT_LLM_REASONING_EFFORT is missing or set to blank. Expected result: reasoningEffort is null. + */ + @Test + void should_returnNullReasoningEffort_When_valueIsAbsentOrBlank() { + LlmConfig absent = LlmConfig.fromMap(Map.of()); + LlmConfig blank = LlmConfig.fromMap(Map.of("A2AT_LLM_REASONING_EFFORT", "")); + + assertNull(absent.reasoningEffort()); + assertNull(blank.reasoningEffort()); + } + + @Test + void shouldRecordParseErrors_When_numericValuesAreNonNumeric() { + Map values = Map.of( + "A2AT_LLM_MAX_TOKENS", "garbage", + "A2AT_LLM_TEMPERATURE", "xyz", + "A2AT_LLM_TIMEOUT_SECONDS", "abc", + "A2AT_LLM_HISTORY_WINDOW", "not_a_number", + "A2AT_LLM_SESSION_MAX_TOTAL", "bad", + "A2AT_LLM_SESSION_MAX_PER_PROVIDER", "nope"); + + LlmConfig config = LlmConfig.fromMap(values); + + List errors = config.parseErrors(); + assertEquals(6, errors.size()); + assertTrue(errors.contains("A2AT_LLM_MAX_TOKENS: non-numeric value 'garbage'")); + assertTrue(errors.contains("A2AT_LLM_TEMPERATURE: non-numeric value 'xyz'")); + assertTrue(errors.contains("A2AT_LLM_TIMEOUT_SECONDS: non-numeric value 'abc'")); + assertTrue(errors.contains("A2AT_LLM_HISTORY_WINDOW: non-numeric value 'not_a_number'")); + assertTrue(errors.contains("A2AT_LLM_SESSION_MAX_TOTAL: non-numeric value 'bad'")); + assertTrue(errors.contains("A2AT_LLM_SESSION_MAX_PER_PROVIDER: non-numeric value 'nope'")); + + assertNull(config.maxTokens()); + assertNull(config.temperature()); + assertNull(config.timeoutSeconds()); + assertEquals(10, config.historyWindow()); + assertEquals(300, config.sessionMaxTotal()); + assertEquals(100, config.sessionMaxPerProvider()); + } + + @Test + void shouldNotRecordMaxAttemptsInParseErrors_When_maxAttemptsIsNonNumeric() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_MAX_ATTEMPTS", "garbage")); + + assertTrue(config.parseErrors().isEmpty()); + assertEquals(3, config.maxAttempts()); + } + + @Test + void shouldReturnEmptyParseErrors_When_noErrors() { + Map values = Map.of( + "A2AT_LLM_PROVIDER", "openai", + "A2AT_LLM_MAX_TOKENS", "4096", + "A2AT_LLM_TEMPERATURE", "0.5", + "A2AT_LLM_HISTORY_WINDOW", "20", + "A2AT_LLM_MAX_ATTEMPTS", "5"); + + LlmConfig config = LlmConfig.fromMap(values); + + assertTrue(config.parseErrors().isEmpty()); + assertEquals(4096, config.maxTokens()); + assertEquals(0.5d, config.temperature()); + assertEquals(20, config.historyWindow()); + assertEquals(5, config.maxAttempts()); + } + + @Test + void shouldReturnEmptyParseErrors_When_noValuesProvided() { + LlmConfig config = LlmConfig.fromMap(Map.of()); + + assertTrue(config.parseErrors().isEmpty()); + } + + @Test + void shouldRecordParseError_When_booleanValueIsInvalid() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_DISABLE_SYSTEM_PROXY", "maybe")); + + List errors = config.parseErrors(); + assertEquals(1, errors.size()); + assertTrue(errors.contains("A2AT_LLM_DISABLE_SYSTEM_PROXY: invalid boolean value 'maybe'")); + assertEquals(false, config.disableSystemProxy()); + } + + @Test + void shouldParseBooleanValueCaseInsensitive() { + LlmConfig trueConfig = LlmConfig.fromMap(Map.of("A2AT_LLM_DISABLE_SYSTEM_PROXY", "TRUE")); + LlmConfig falseConfig = LlmConfig.fromMap(Map.of("A2AT_LLM_DISABLE_SYSTEM_PROXY", "False")); + + assertEquals(true, trueConfig.disableSystemProxy()); + assertEquals(false, falseConfig.disableSystemProxy()); + assertTrue(trueConfig.parseErrors().isEmpty()); + assertTrue(falseConfig.parseErrors().isEmpty()); + } + + /** Verifies that {@code A2AT_LLM_DETAIL_LOG_ENABLED} resolves to {@code false} when the key is missing or blank. */ + @Test + void should_defaultDetailLogEnabledToFalse_When_keyIsMissingOrBlank() { + LlmConfig absent = LlmConfig.fromMap(Map.of()); + LlmConfig blank = LlmConfig.fromMap(Map.of("A2AT_LLM_DETAIL_LOG_ENABLED", "")); + + assertEquals(false, absent.detailLogEnabled()); + assertEquals(false, blank.detailLogEnabled()); + assertTrue(absent.parseErrors().isEmpty()); + assertTrue(blank.parseErrors().isEmpty()); + } + + /** + * Verifies that {@code A2AT_LLM_DETAIL_LOG_ENABLED} is parsed case-insensitively without recording parse errors. + */ + @Test + void should_parseDetailLogEnabledCaseInsensitive() { + LlmConfig trueConfig = LlmConfig.fromMap(Map.of("A2AT_LLM_DETAIL_LOG_ENABLED", "TRUE")); + LlmConfig falseConfig = LlmConfig.fromMap(Map.of("A2AT_LLM_DETAIL_LOG_ENABLED", "False")); + + assertEquals(true, trueConfig.detailLogEnabled()); + assertEquals(false, falseConfig.detailLogEnabled()); + assertTrue(trueConfig.parseErrors().isEmpty()); + assertTrue(falseConfig.parseErrors().isEmpty()); + } + + /** + * Verifies that an invalid {@code A2AT_LLM_DETAIL_LOG_ENABLED} value records a parse error and falls back to + * {@code false}. + */ + @Test + void should_recordParseError_When_detailLogEnabledIsInvalid() { + LlmConfig config = LlmConfig.fromMap(Map.of("A2AT_LLM_DETAIL_LOG_ENABLED", "maybe")); + + List errors = config.parseErrors(); + assertEquals(1, errors.size()); + assertTrue(errors.contains("A2AT_LLM_DETAIL_LOG_ENABLED: invalid boolean value 'maybe'")); + assertEquals(false, config.detailLogEnabled()); } } diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java new file mode 100644 index 00000000..29a3fe70 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/MetadataContentTest.java @@ -0,0 +1,140 @@ +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import org.junit.jupiter.api.Test; + +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, NegotiationPerformative.PROPOSE); + + @Test + 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 + void recordsWithSameValuesAreEqual() { + MetadataContent first = new MetadataContent("template-uri", "prompt-text", "extension-uri"); + MetadataContent second = new MetadataContent("template-uri", "prompt-text", "extension-uri"); + + assertEquals(first, second); + assertNotSame(first, second); + } + + @Test + void exposesNoStaticFactoryMethods() { + assertThrows(NoSuchMethodException.class, () -> MetadataContent.class.getMethod("success")); + assertThrows(NoSuchMethodException.class, () -> MetadataContent.class.getMethod("failure")); + } + + @Test + void buildMetadataContentReturnsExactlyTwoDeterministicEntriesWithoutContext() { + MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message", "https://example/ext"); + + Map metadata = content.buildMetadataContent(); + + assertEquals(2, metadata.size()); + assertEquals("rendered message", metadata.get(content.extensionUri())); + assertEquals(TEMPLATE_URI, metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(metadata, content.buildMetadataContent()); + assertTrue(metadata.containsKey(content.extensionUri())); + } + + @Test + void buildMetadataContentKeepsFixedKeyOrder() { + MetadataContent content = new MetadataContent(TEMPLATE_URI, "rendered message", "https://example/ext"); + + Map metadata = content.buildMetadataContent(); + + assertEquals( + java.util.List.of(content.extensionUri(), MetadataContent.TEMPLATE_URI_METADATA_KEY), + new java.util.ArrayList<>(metadata.keySet())); + } + + @Test + void buildMetadataContentNeverReturnsNullEvenWithNullFields() { + MetadataContent content = new MetadataContent(null, null, "extension-uri"); + + Map metadata = content.buildMetadataContent(); + + assertEquals(2, metadata.size()); + assertTrue(metadata.containsKey(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("PROPOSE", nestedContext.get("performative")); + assertEquals(4, nestedContext.size()); + assertEquals(metadata, content.buildMetadataContent()); + } + + @Test + void buildMetadataContextCarriesPerformativeAsFourthKeyInUpperCase() { + NegotiationContext context = + new NegotiationContext("session-id", 2, 5, NegotiationPerformative.REJECT); + MetadataContent content = + new MetadataContent(TEMPLATE_URI, "rendered message", "https://example/ext", context); + + Map metadata = content.buildMetadataContent(); + + @SuppressWarnings("unchecked") + Map nestedContext = + (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + assertEquals( + java.util.List.of("id", "round", "maxRounds", "performative"), + new java.util.ArrayList<>(nestedContext.keySet())); + assertEquals("REJECT", nestedContext.get("performative")); + assertEquals("session-id", nestedContext.get("id")); + assertEquals(2, nestedContext.get("round")); + assertEquals(5, nestedContext.get("maxRounds")); + assertEquals(metadata, content.buildMetadataContent()); + } + + @Test + void buildMetadataContextPinsUpperCaseWireValueForEveryPerformative() { + for (NegotiationPerformative performative : NegotiationPerformative.values()) { + NegotiationContext context = new NegotiationContext("session-id", 1, 5, performative); + MetadataContent content = + new MetadataContent(TEMPLATE_URI, "rendered message", "https://example/ext", context); + + Map metadata = content.buildMetadataContent(); + + @SuppressWarnings("unchecked") + Map nestedContext = + (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + assertEquals(performative.name(), nestedContext.get("performative")); + } + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/NegotiationContextTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/NegotiationContextTest.java new file mode 100644 index 00000000..3ef4141f --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/NegotiationContextTest.java @@ -0,0 +1,177 @@ +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, NegotiationPerformative.PROPOSE); + + assertEquals(SESSION_ID, context.id()); + assertEquals(1, context.round()); + assertEquals(5, context.maxRounds()); + assertEquals(NegotiationPerformative.PROPOSE, context.performative()); + } + + @Test + void nullPerformativeThrowsWithDistinguishableMessage() { + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> new NegotiationContext(SESSION_ID, 1, 5, null)); + + assertTrue(exception.getMessage().contains("performative")); + assertTrue(exception.getMessage().contains("null")); + } + + @Test + void withPerformativeStampsIntentAndKeepsSessionFields() { + NegotiationContext handle = new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.PROPOSE); + NegotiationContext stamped = handle.withPerformative(NegotiationPerformative.ACCEPT); + + assertEquals(NegotiationPerformative.ACCEPT, stamped.performative()); + assertEquals(SESSION_ID, stamped.id()); + assertEquals(2, stamped.round()); + assertEquals(5, stamped.maxRounds()); + assertEquals(NegotiationPerformative.PROPOSE, handle.performative()); + assertNotEquals(handle, stamped); + } + + @Test + void withPerformativeOverwritesPreviousStamp() { + NegotiationContext first = new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.PROPOSE); + NegotiationContext second = first.withPerformative(NegotiationPerformative.REJECT); + + assertEquals(NegotiationPerformative.REJECT, second.performative()); + assertEquals(NegotiationPerformative.PROPOSE, first.performative()); + assertNotEquals(first, second); + } + + @Test + void withPerformativeRejectsNull() { + NegotiationContext context = new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.PROPOSE); + + assertThrows(IllegalArgumentException.class, () -> context.withPerformative(null)); + } + + @Test + void nextRoundCarriesPerformative() { + NegotiationContext context = new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.PROPOSE); + NegotiationContext advanced = context.nextRound(); + + assertEquals(NegotiationPerformative.PROPOSE, advanced.performative()); + assertEquals(2, advanced.round()); + assertEquals(NegotiationPerformative.PROPOSE, context.performative()); + } + + @Test + void equalsTakesPerformativeIntoAccount() { + NegotiationContext withPropose = new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.PROPOSE); + NegotiationContext withAbort = new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.ABORT); + NegotiationContext samePropose = new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.PROPOSE); + + assertNotEquals(withPropose, withAbort); + assertEquals(withPropose, samePropose); + } + + @Test + void blankIdThrowsWithDistinguishableMessage() { + IllegalArgumentException blankException = + assertThrows( + IllegalArgumentException.class, + () -> new NegotiationContext("", 1, 5, NegotiationPerformative.PROPOSE)); + IllegalArgumentException whitespaceException = + assertThrows( + IllegalArgumentException.class, + () -> new NegotiationContext(" ", 1, 5, NegotiationPerformative.PROPOSE)); + IllegalArgumentException nullException = + assertThrows( + IllegalArgumentException.class, + () -> new NegotiationContext(null, 1, 5, NegotiationPerformative.PROPOSE)); + + 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, NegotiationPerformative.PROPOSE)); + + assertTrue(exception.getMessage().contains("round")); + assertTrue(exception.getMessage().contains("0")); + } + + @Test + void negativeRoundThrowsWithDistinguishableMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new NegotiationContext(SESSION_ID, -1, 5, NegotiationPerformative.PROPOSE)); + + assertTrue(exception.getMessage().contains("round")); + assertTrue(exception.getMessage().contains("-1")); + } + + @Test + void zeroMaxRoundsThrowsWithDistinguishableMessage() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new NegotiationContext(SESSION_ID, 1, 0, NegotiationPerformative.PROPOSE)); + + assertTrue(exception.getMessage().contains("maxRounds")); + assertTrue(exception.getMessage().contains("0")); + } + + @Test + void roundGreaterThanMaxRoundsIsAllowedAtConstruction() { + NegotiationContext context = new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.PROPOSE); + + assertEquals(6, context.round()); + assertTrue(context.isExhausted()); + } + + @Test + void nextRoundReturnsNewInstanceAndLeavesOriginalUnchanged() { + NegotiationContext context = new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.PROPOSE); + 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, NegotiationPerformative.PROPOSE).isExhausted()); + assertFalse(new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.PROPOSE).isExhausted()); + assertTrue(new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.PROPOSE).isExhausted()); + } + + @Test + void factoryAppliesDefaultMaxRounds() { + NegotiationContext context = NegotiationContext.of(SESSION_ID, 2, NegotiationPerformative.PROPOSE); + + assertEquals(5, NegotiationContext.DEFAULT_MAX_ROUNDS); + assertEquals(5, context.maxRounds()); + assertEquals(2, context.round()); + assertEquals(SESSION_ID, context.id()); + assertEquals(NegotiationPerformative.PROPOSE, context.performative()); + } + + @Test + void factoryRejectsNullPerformative() { + assertThrows(IllegalArgumentException.class, () -> NegotiationContext.of(SESSION_ID, 2, null)); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/NegotiationPerformativeTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/NegotiationPerformativeTest.java new file mode 100644 index 00000000..4ba6579a --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/NegotiationPerformativeTest.java @@ -0,0 +1,61 @@ +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.assertTrue; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class NegotiationPerformativeTest { + + @Test + void valuesPinTheFourWireConstantsInDeclarationOrder() { + List constants = List.of(NegotiationPerformative.values()); + + assertEquals(4, constants.size()); + assertEquals( + List.of( + NegotiationPerformative.PROPOSE, + NegotiationPerformative.ACCEPT, + NegotiationPerformative.REJECT, + NegotiationPerformative.ABORT), + constants); + } + + @Test + void namesArePinnedToUpperCaseWireValues() { + assertEquals("PROPOSE", NegotiationPerformative.PROPOSE.name()); + assertEquals("ACCEPT", NegotiationPerformative.ACCEPT.name()); + assertEquals("REJECT", NegotiationPerformative.REJECT.name()); + assertEquals("ABORT", NegotiationPerformative.ABORT.name()); + } + + @Test + void tryParseAcceptsTheExactWireValues() { + assertEquals(Optional.of(NegotiationPerformative.PROPOSE), NegotiationPerformative.tryParse("PROPOSE")); + assertEquals(Optional.of(NegotiationPerformative.ACCEPT), NegotiationPerformative.tryParse("ACCEPT")); + assertEquals(Optional.of(NegotiationPerformative.REJECT), NegotiationPerformative.tryParse("REJECT")); + assertEquals(Optional.of(NegotiationPerformative.ABORT), NegotiationPerformative.tryParse("ABORT")); + } + + @Test + void tryParseRejectsLowerCaseVariants() { + assertTrue(NegotiationPerformative.tryParse("propose").isEmpty()); + assertTrue(NegotiationPerformative.tryParse("accept").isEmpty()); + assertTrue(NegotiationPerformative.tryParse("reject").isEmpty()); + assertTrue(NegotiationPerformative.tryParse("abort").isEmpty()); + } + + @Test + void tryParseRejectsNullAndUnknownValues() { + assertTrue(NegotiationPerformative.tryParse(null).isEmpty()); + assertTrue(NegotiationPerformative.tryParse("").isEmpty()); + assertTrue(NegotiationPerformative.tryParse("Propose").isEmpty()); + assertTrue(NegotiationPerformative.tryParse("PROPOSE ").isEmpty()); + assertTrue(NegotiationPerformative.tryParse("COUNTER").isEmpty()); + assertTrue(NegotiationPerformative.tryParse("PROPOSE, ACCEPT").isEmpty()); + assertFalse(NegotiationPerformative.tryParse("ABORT").isEmpty()); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/OperationResultTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/OperationResultTest.java index 666930d2..ea0f1145 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/OperationResultTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/OperationResultTest.java @@ -6,7 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.exception.A2ATError; import org.junit.jupiter.api.Test; /** @@ -15,7 +15,7 @@ *

Tests cover the following scenarios: *

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

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

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

Scenario: Attempt to create a failure result with a null error. * Expected result: NullPointerException is thrown. @@ -67,4 +67,4 @@ void should_carryErrorWithoutValue_When_failureResultCreated() { void should_throwNullPointerException_When_failureResultCreatedWithNullError() { assertThrows(NullPointerException.class, () -> OperationResult.failure(null)); } -} \ No newline at end of file +} 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..bd6965a4 100644 --- a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptRuntimeConfigTest.java @@ -1,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; @@ -9,9 +10,10 @@ * Unit tests for {@link PromptRuntimeConfig}. * *

Tests cover the following scenarios: + * *

    - *
  • Default values when configuration keys are missing
  • - *
  • Overriding defaults with environment variable values
  • + *
  • Default values when configuration keys are missing + *
  • Overriding defaults with environment variable values *
* * @since 2026-06 @@ -19,14 +21,11 @@ class PromptRuntimeConfigTest { /** - * Verifies that {@link PromptRuntimeConfig#fromMap(Map)} applies default values - * when no configuration keys are provided. + * 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: "." (current directory) + *

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() { @@ -36,15 +35,15 @@ void should_useDefaults_When_keysAreMissing() { assertEquals("en-US", config.language()); assertEquals("classpath", config.sourceType()); - assertEquals(".", config.localRootDir()); + assertNull(config.localRootDir()); } /** - * Verifies that {@link PromptRuntimeConfig#fromMap(Map)} overrides default values - * with values from the provided map. + * 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. + *

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() { @@ -59,4 +58,4 @@ void should_overrideDefaults_When_keysAreProvided() { assertEquals("local_file", config.sourceType()); assertEquals("/path/to/prompts", config.localRootDir()); } -} \ 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 new file mode 100644 index 00000000..c6dc137b --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/PromptTemplateTest.java @@ -0,0 +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()); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/StandardTemplatesStringConstantsTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/StandardTemplatesStringConstantsTest.java new file mode 100644 index 00000000..c9629494 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/StandardTemplatesStringConstantsTest.java @@ -0,0 +1,78 @@ +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the parallel string constant set of {@link StandardTemplates}. + * + *

Pins the typed {@link TemplateUri} constants and their raw {@code _URI} string twins in lockstep so the two + * spellings of the same template cannot drift apart, and mirrors the family aggregates the same way. + * + * @since 2026-08 + */ +class StandardTemplatesStringConstantsTest { + + @Test + void should_keepTypedAndStringConstantsInLockstep() { + assertEquals(StandardTemplates.ENERGY_SAVING.uri(), StandardTemplates.ENERGY_SAVING_URI); + assertEquals(StandardTemplates.PRIVATE_LINE_COMPLAINT.uri(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + assertEquals(StandardTemplates.SUBSCRIBE_INCIDENT.uri(), StandardTemplates.SUBSCRIBE_INCIDENT_URI); + assertEquals(StandardTemplates.SERVICE_RECOVERY.uri(), StandardTemplates.SERVICE_RECOVERY_URI); + assertEquals( + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT.uri(), + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI); + assertEquals( + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI); + assertEquals( + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); + assertEquals(StandardTemplates.TARGET_NEGOTIATION_PROPOSE.uri(), StandardTemplates.TARGET_NEGOTIATION_PROPOSE_URI); + assertEquals( + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI); + assertEquals( + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE_URI); + assertEquals( + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT.uri(), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI); + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), StandardTemplates.NEGOTIATION_ABORT_URI); + } + + @Test + void should_keepFamilyAggregatesInLockstep() { + assertEquals(uriList(StandardTemplates.TASK), StandardTemplates.TASK_URIS); + assertEquals(uriList(StandardTemplates.NOTIFICATION), StandardTemplates.NOTIFICATION_URIS); + assertEquals(uriList(StandardTemplates.AUTHORIZATION), StandardTemplates.AUTHORIZATION_URIS); + assertEquals(uriList(StandardTemplates.NEGOTIATION), StandardTemplates.NEGOTIATION_URIS); + } + + @Test + void should_exposeOneStringConstantPerTypedConstant() { + List typed = Arrays.stream(StandardTemplates.class.getDeclaredFields()) + .filter(field -> field.getType() == TemplateUri.class) + .map(Field::getName) + .sorted() + .toList(); + List strings = Arrays.stream(StandardTemplates.class.getDeclaredFields()) + .filter(field -> field.getType() == String.class) + .map(Field::getName) + .filter(name -> name.endsWith("_URI")) + .sorted() + .toList(); + // every typed constant ENERGY_SAVING must have exactly one string twin ENERGY_SAVING_URI, and no other + // *_URI string constant may exist + List expected = typed.stream().map(name -> name + "_URI").sorted().toList(); + assertEquals(expected, strings); + } + + private static List uriList(List uris) { + return uris.stream().map(TemplateUri::uri).toList(); + } +} 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 new file mode 100644 index 00000000..ca1d2a97 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/model/TemplateUriTest.java @@ -0,0 +1,75 @@ +package net.openan.a2at.sdk.core.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +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", "network-layer", "ran-energy-saving"); + assertEquals("Task-T/network-layer/ran-energy-saving/v1", uri.uri()); + assertEquals("Task-T", uri.extensionName()); + assertEquals(List.of("network-layer", "ran-energy-saving"), uri.pathSegments()); + assertEquals("v1", uri.templateVersion()); + } + + @Test + void parseRoundTripsNetworkLayerUri() { + Optional parsed = TemplateUri.parse("Task-T/network-layer/ran-energy-saving/v1"); + assertEquals( + Optional.of(TemplateUri.of("Task-T", "network-layer", "ran-energy-saving")), parsed); + assertEquals("Task-T/network-layer/ran-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", "authorization-policy-management")), parsed); + } + + @Test + void parseRoundTripsNegotiationUri() { + Optional parsed = TemplateUri.parse("Negotiation-T/information-negotiation/propose/v1"); + assertEquals( + Optional.of(TemplateUri.of("Negotiation-T", "information-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/network-layer/ran-energy-saving/../etc")); + assertEquals(Optional.empty(), TemplateUri.parse("Task-T/network-layer/energy\\saving/v1")); + assertEquals(Optional.empty(), TemplateUri.parse("Task-T//ran-energy-saving/v1")); + } + + @Test + void ofRejectsInvalidComponents() { + 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", "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 + void pathSegmentsAreDefensivelyCopied() { + ArrayList segments = new ArrayList<>(List.of("network-layer", "ran-energy-saving")); + TemplateUri uri = new TemplateUri("Task-T", segments, "v1"); + segments.add("tampered"); + assertEquals(List.of("network-layer", "ran-energy-saving"), uri.pathSegments()); + } +} 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..428d04b5 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/resources/PathSegmentsTest.java @@ -0,0 +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"); + }); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/telemetry/TelemetryHookTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/telemetry/TelemetryHookTest.java new file mode 100644 index 00000000..3b24d7c2 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/telemetry/TelemetryHookTest.java @@ -0,0 +1,47 @@ +package net.openan.a2at.sdk.core.telemetry; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class TelemetryHookTest { + + @Test + void noOpHookAbsorbsEverything() { + TelemetryOperation operation = TelemetryHook.NO_OP.startSdkOperation("client", "getPrompts"); + assertSame(TelemetryOperation.NO_OP, operation); + assertDoesNotThrow(() -> { + operation.setAttribute("k", "v"); + operation.markSuccess(); + operation.markError(new RuntimeException("x")); + operation.close(); + TelemetryHook.NO_OP.reportNegotiationRounds("n1", "accept", 3); + }); + } + + @Test + void customHookReceivesCalls() { + List started = new ArrayList<>(); + List rounds = new ArrayList<>(); + TelemetryHook hook = new TelemetryHook() { + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + started.add(role + "." + method); + return TelemetryOperation.NO_OP; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + }; + hook.startSdkOperation("server", "checkTaskPrompt").close(); + hook.reportNegotiationRounds("N1", "reject", 2); + assertTrue(started.contains("server.checkTaskPrompt")); + assertTrue(rounds.contains("N1:reject:2")); + } +} diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ContentValidationExceptionTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ContentValidationExceptionTest.java new file mode 100644 index 00000000..51a34d5c --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/validation/ContentValidationExceptionTest.java @@ -0,0 +1,52 @@ +package net.openan.a2at.sdk.core.validation; + +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.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Guards {@link ContentValidationException} param/error handling against the shapes produced by the semantic + * validator: a {@code null} params map, a params map carrying {@code null} values (the semantic validator emits + * {@code null} for slots it could not extract), and a {@code null} error list must all be accepted defensively. + */ +class ContentValidationExceptionTest { + + @Test + void should_AcceptNullParamsAndErrors() { + ContentValidationException exception = new ContentValidationException("code", "message", null, null, null); + + assertTrue(exception.params().isEmpty()); + assertTrue(exception.errors().isEmpty()); + } + + @Test + void should_AcceptParamsWithNullValues_FromPartialExtraction() { + Map partial = new HashMap<>(); + partial.put("accessPort", null); // slot not extractable -> null per the semantic validator contract + partial.put("bizScenario", "专线中断"); + + ContentValidationException exception = + new ContentValidationException("code", "message", List.of(), partial, null); + + assertEquals(2, exception.params().size()); + assertNull(exception.params().get("accessPort")); + assertEquals("专线中断", exception.params().get("bizScenario")); + } + + @Test + void should_ExposeParamsAndErrorsAsUnmodifiableCopies() { + Map partial = new HashMap<>(); + partial.put("accessPort", "P781-珠江新城-PTN7900-23-TPA1EG24-17"); + ContentValidationException exception = + new ContentValidationException("code", "message", List.of(), partial, null); + + assertThrows(UnsupportedOperationException.class, () -> exception.params().put("k", "v")); + assertThrows(UnsupportedOperationException.class, () -> exception.errors().add(null)); + } +} \ No newline at end of file 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..e35fe131 --- /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.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import org.junit.jupiter.api.Test; + +/** + * Guards the optional template loading gate of {@link ValidationPipeline}: the gate must run after the rule gate and + * before semantic validation, a loader failure must map to {@code template.not_found}, and the loader-less default + * {@code validate} variant must reject callers that did not provide the template content. + */ +class ValidationPipelineTest { + + @Test + void should_loadTemplateBetweenRuleGateAndSemanticValidation() { + List events = new ArrayList<>(); + RuleChecker ruleChecker = prompt -> { + events.add("rule"); + return Map.of(); + }; + SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> { + events.add("semantic"); + return new ValidationResult(true, List.of(), Map.of("param", "value")); + }; + TemplateContentLoader loader = reference -> { + events.add("load"); + return "template-content"; + }; + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, null, loader); + + pipeline.validate("prompt", Map.of(), "ref"); + + assertEquals(List.of("rule", "load", "semantic"), events); + } + + @Test + void should_mapLoaderFailureToPromptResourceNotFound() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + TemplateContentLoader loader = reference -> { + throw new ResourceNotFoundException("missing template", reference); + }; + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, null, loader); + + ContentValidationException exception = + assertThrows(ContentValidationException.class, () -> pipeline.validate("prompt", Map.of(), "ref")); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), exception.getCode()); + } + + @Test + void should_throwValidationInvalidInput_When_schemaIsNull() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, () -> pipeline.validate("prompt", null, "ref", "content")); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); + } + + @Test + void should_throwValidationInvalidInput_When_promptIsNullOrBlank() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + ContentValidationException nullException = assertThrows( + ContentValidationException.class, () -> pipeline.validate(null, Map.of(), "ref", "content")); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), nullException.getCode()); + + ContentValidationException blankException = assertThrows( + ContentValidationException.class, () -> pipeline.validate(" ", Map.of(), "ref", "content")); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), blankException.getCode()); + } + + @Test + void should_throwValidationInvalidInput_When_referenceIsNull() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, () -> pipeline.validate("prompt", Map.of(), null, "content")); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); + } + + @Test + void should_throwIllegalStateException_When_noLoaderInjected() { + RuleChecker ruleChecker = prompt -> Map.of(); + SemanticValidator semanticValidator = + (prompt, schema, reference, templateContent) -> new ValidationResult(true, List.of(), Map.of()); + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1); + + assertThrows(IllegalStateException.class, () -> pipeline.validate("prompt", Map.of(), "ref")); + } + + @Test + void should_notInvokeLoader_When_templateContentProvidedExplicitly() { + List events = new ArrayList<>(); + RuleChecker ruleChecker = prompt -> { + events.add("rule"); + return Map.of(); + }; + SemanticValidator semanticValidator = (prompt, schema, reference, templateContent) -> { + events.add("semantic"); + return new ValidationResult(true, List.of(), Map.of()); + }; + TemplateContentLoader loader = reference -> { + events.add("load"); + return "template-content"; + }; + + ValidationPipeline pipeline = new ValidationPipeline<>(ruleChecker, semanticValidator, 1, null, loader); + + pipeline.validate("prompt", Map.of(), "ref", "explicit-content"); + + assertEquals(List.of("rule", "semantic"), events); + } +} diff --git a/a2a-t-core/src/test/resources/logback-test.xml b/a2a-t-core/src/test/resources/logback-test.xml new file mode 100644 index 00000000..a9800f0f --- /dev/null +++ b/a2a-t-core/src/test/resources/logback-test.xml @@ -0,0 +1,11 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/a2a-t-corpus/README.md b/a2a-t-corpus/README.md new file mode 100644 index 00000000..de34abde --- /dev/null +++ b/a2a-t-corpus/README.md @@ -0,0 +1,143 @@ +# a2a-t-corpus - A2A-T Accuracy Verification Corpus + +Data-driven accuracy verification for the A2A-T SDK: workflow cases written as JSON run against a **real LLM** +through the production SDK assembly path, with per-step API/LLM transcripts, latency and token metrics, and 95%+ accuracy +target reporting for prompt tuning. + +Current extensions: **Task-T** (private-line complaint diagnosis) and **Negotiation-T** (RAN energy-saving target / +information / feasibility negotiation), each as the benchmark pilot; Notification-T / Authorization-T follow the same +structure once their reference scenarios stabilize (suite class + `resources/` + registry entries, zero fork). + +- **Pure test module**: no `src/main`, not in the BOM, excluded from release artifacts + (`maven.deploy.skip/source.skip/javadoc.skip=true`) and from CI (`mvn ... -pl '!a2a-t-corpus'`). +- **A real LLM is mandatory**: running without the three required `A2AT_LLM_*` values fails fast with guidance. + The `.env` keys reuse the project-root `env.example` naming, so the corpus template is a tuned subset of the + root one. + +## Layout + +``` +a2a-t-corpus/ +├── pom.xml / env.example / .env (secrets, gitignored) / README.md / README_zh.md +├── schemas/ input-case.schema.json, output-result.schema.json (first-hand format definitions) +├── tools/ inputCsvToJson(.py/.sh), outputJsonToCsv(.py/.sh), csv-templates/ +└── src/test/java/net/openan/a2at/sdk/corpus/ + ├── engine/ framework core: config/loader/registry/engine/llm/assertion/report/discover + CorpusWorkFlowSuite + └── suites/ the per-extension workflow suites + ├── task/ TaskTFromTextWorkFlowTest, TaskTFromDataWorkFlowTest, TaskSelfGuardTest, + │ └── resources// input_case_from_text.json, input_case_from_data.json + │ (+ output_result_*.json written back by each run) + └── negotiation/ NegotiationTFromTextWorkFlowTest, NegotiationTFromDataWorkFlowTest, + NegotiationSelfGuardTest + └── resources// ran-energy-saving-target-negotiation, + ran-energy-saving-information-negotiation, + ran-energy-saving-feasibility-negotiation +``` + +`suites//resources//` holds `input_case_from_text.json` and `input_case_from_data.json` +(case design, hand-authored); `output_result_*.json` files are written back by the engine after each run. + +## Quick start + +```bash +cp a2a-t-corpus/env.example a2a-t-corpus/.env # fill A2AT_LLM_BASE_URL / API_KEY / MODEL +mvn -pl a2a-t-corpus -am test -Dtest=TaskTFromTextWorkFlowTest +mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario=private-line-complaint +mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario='private-*' -Dcase.filter='TC0000000*' +# Negotiation-T scenarios (RAN energy-saving) +mvn -pl a2a-t-corpus test -Dtest=NegotiationTFromTextWorkFlowTest -Dcorpus.scenario='ran-energy-saving-*' +``` + +- `-Dcorpus.scenario`: scenario name glob (`*` wildcard, comma-separated); `-Dcase.filter`: case id glob. +- `-Dtest` filters apply reactor-wide under `-am`; the parent POM configures surefire + `failIfNoSpecifiedTests=false`, so upstream modules without a matching test class do not fail the build. +- Every executed case is printed to the console as it completes; failed/crashed cases print their full interaction + trace there too. The transcript file reflects exactly this run (a filtered run overwrites it with the matching + cases only). +- `-Dcorpus.output.dir=

`: redirect outputs instead of writing back into the scenario directories + (summaries default to `target/corpus/`). +- Structure-only gates without an LLM: `mvn -pl a2a-t-corpus test -Dtest=TaskSelfGuardTest,NegotiationSelfGuardTest`. + +## Case JSON contract (v1, first-hand definitions in `schemas/`) + +```json +{ + "id": "TC00000001", "caseDimension": "01-意图清晰参数完整", "caseDesc": "标准专线业务中断投诉", + "caseCategory": "正常", + "input": [ + { "api": "generateTaskPromptFromText", + "args": { "text": "发生专线业务中断,接入端口名称为P781-……", "templateUri": "Task-T/network-layer/private-line-complaint/v1" } }, + { "api": "validateTaskPromptAndDataFilling", + "args": { "schema": { "…": "JSON Schema" }, "templateUri": "…", "promptText": { "$fromStep": 1, "$field": "promptText" } } } + ], + "expected": [ + { "result": "success", "data": {}, "error": { "errCode": "", "errMessage": "" } }, + { "result": "success", "data": { "accessPort": "P781-……" }, "error": { "errCode": "", "errMessage": "" } } + ] +} +``` + +- `id` uses the `TC` (TestCase) prefix, e.g. `TC00000001`; ids are unique within one case file. +- `input` / `expected` are equal-length arrays; order is the execution order; any step count is allowed and the same + API may appear repeatedly. +- `expected.result`: `success` / `error`; `data` is a subset assertion (optional `dataExact` tightens to full + equality); `error.errCode` must be a code from the SDK error catalog. +- `{"$fromStep": N, "$field": "promptText"}` copies a field from the response of an earlier step (1-based); + literal values always win. +- `caseCategory` is an open enumeration, e.g. `正常` / `异常` / `边界` / `模糊语义` ... (aggregation key only, + never coupled to assertions). +- Loading is strict: unknown keys, missing expectations, mismatched array lengths, unregistered api names and + out-of-catalog error codes all fail at load time with aggregated diagnostics. +- Business payloads (input texts, schema descriptions, expected slot values) and the case metadata + (`caseDimension` / `caseDesc` / `caseCategory`) stay in Chinese because they describe business cases for the + zh-CN prompt resources; everything else (tooling, messages, code) is English. + +## Outputs and metrics + +- Per scenario: `output_result_from_text.json` / `output_result_from_data.json` with one record per case whose + interactions interleave **API steps and their underlying LLM calls**, carrying complete requests/responses + (error steps dump every exception property plus the cause chain), latency and tokens - complete output, never + truncated. +- `a2a-t-corpus/target/corpus/summary_report_.json` + console table: accuracy overall / per scenario / per + dimension / per category / per API with the **95% target line annotated**, latency p50/p95, token totals. + +## Tooling loop + +```bash +python a2a-t-corpus/tools/inputCsvToJson.py --template --out my-cases.csv # design table (with an example row) +# fill the table, then convert with structural validation on by default +python a2a-t-corpus/tools/inputCsvToJson.py --csv my-cases.csv \ + --out a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources//input_case_from_text.json +# run a suite, then review (one row per case, full per-step request/response) +python a2a-t-corpus/tools/outputJsonToCsv.py \ + --json a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources//output_result_from_text.json \ + --out review.csv +# optional: fill input JSON back into the design table +python a2a-t-corpus/tools/inputCsvToJson.py --reverse --csv --out back.csv +``` + +Run the Python scripts directly on any platform; `tools/*.sh` are thin bash wrappers that forward every argument 1:1, +so any command above works by swapping `python a2a-t-corpus/tools/.py` for `tools/.sh`: + +```bash +tools/inputCsvToJson.sh --template --out my-cases.csv +tools/inputCsvToJson.sh --csv my-cases.csv --out /input_case_from_text.json +tools/outputJsonToCsv.sh --json /output_result_from_text.json --out review.csv +tools/inputCsvToJson.sh --reverse --csv --out back.csv +``` + +The wrappers (Git Bash / WSL / Linux / macOS) are **not** meant to be double-clicked: double-clicking starts them +without arguments, so they only print the usage text (and the terminal may close right away). Run them from a terminal +with the arguments shown above, or run `python a2a-t-corpus/tools/...` directly when no bash shell is available. The +wrappers resolve `python3` (falling back to `python`) from the terminal PATH and fail with a clear message when no +Python interpreter is installed. + +## Adding a scenario (zero Java changes) + +Create `suites//resources//` with the two input JSON files - the `@TestFactory` suites discover +scenarios at runtime. Run the matching `*SelfGuardTest` (no LLM) as the structural gate first. + +## Later phases (after the reference scenarios stabilize) + +For Notification-T / Authorization-T: add `suites//` suites plus their `resources/`, +register the extension facade methods in `ApiRegistry`, and reuse the framework and tools as-is. diff --git a/a2a-t-corpus/README_zh.md b/a2a-t-corpus/README_zh.md new file mode 100644 index 00000000..fde0b222 --- /dev/null +++ b/a2a-t-corpus/README_zh.md @@ -0,0 +1,113 @@ +# a2a-t-corpus — A2A-T 准确性验证语料 + +A2A-T SDK 的数据驱动准确性验证:把 JSON 工作流用例经生产 SDK 装配链路对**真实 LLM** 执行,产出逐步 API/LLM 完整转录、时延与 Token 指标,并以 95%+ 准确率达标线支撑提示词调优。 + +当前实现扩展:**Task-T**(专线投诉诊断)与 **Negotiation-T**(无线节能目标/信息/可行性协商),均为标杆迭代;Notification-T / Authorization-T 待参考场景稳定后按同结构扩展(套件类 + `resources/` + 注册表条目,零分叉)。 + +- **纯测试模块**:无 `src/main`,不进 BOM、不参与发布(`maven.deploy.skip/source.skip/javadoc.skip=true`),且从 CI 排除(`mvn ... -pl '!a2a-t-corpus'`)。 +- **必须配置真实 LLM**:缺少三个必填 `A2AT_LLM_*` 配置时 fail-fast 并给出配置指引。`.env` 键名与项目根 `env.example` 保持一致,corpus 模板即根模板的精调子集。 + +## 目录 + +``` +a2a-t-corpus/ +├── pom.xml / env.example / .env(密钥,gitignore)/ README.md / README_zh.md +├── schemas/ input-case.schema.json、output-result.schema.json(格式一手定义) +├── tools/ inputCsvToJson(.py/.sh)、outputJsonToCsv(.py/.sh)、csv-templates/ +└── src/test/java/net/openan/a2at/sdk/corpus/ + ├── engine/ 框架核心:config/loader/registry/engine/llm/assertion/report/discover + CorpusWorkFlowSuite + └── suites/ 各扩展的准确性验证套件 + ├── task/ TaskTFromTextWorkFlowTest、TaskTFromDataWorkFlowTest、TaskSelfGuardTest、 + │ └── resources/<场景>/ input_case_from_text.json 与 input_case_from_data.json + │ (用例设计,人工构造);output_result_*.json 由引擎每次运行写回 + └── negotiation/ NegotiationTFromTextWorkFlowTest、NegotiationTFromDataWorkFlowTest、 + NegotiationSelfGuardTest + └── resources/<场景>/ ran-energy-saving-target-negotiation、 + ran-energy-saving-information-negotiation、 + ran-energy-saving-feasibility-negotiation +``` + +`suites/<扩展>/resources/<场景>/` 下为 `input_case_from_text.json` 与 `input_case_from_data.json`(用例设计,人工构造);`output_result_*.json` 由引擎在每次运行后写回。 + +## 快速开始 + +```bash +cp a2a-t-corpus/env.example a2a-t-corpus/.env # 填写 A2AT_LLM_BASE_URL / API_KEY / MODEL +mvn -pl a2a-t-corpus -am test -Dtest=TaskTFromTextWorkFlowTest +mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario=private-line-complaint +mvn -pl a2a-t-corpus test -Dtest=TaskTFromTextWorkFlowTest -Dcorpus.scenario='private-*' -Dcase.filter='TC0000000*' +# 无线节能协商场景 +mvn -pl a2a-t-corpus test -Dtest=NegotiationTFromTextWorkFlowTest -Dcorpus.scenario='ran-energy-saving-*' +``` + +- `-Dcorpus.scenario`:场景名 glob(`*` 通配,逗号分隔);`-Dcase.filter`:用例 id glob。 +- `-Dtest` 过滤在 `-am` 下作用于整个反应堆;父 POM 已配置 surefire `failIfNoSpecifiedTests=false`,上游模块无同名测试类不会导致构建失败。 +- 每条已执行用例完成即打印到控制台;失败/崩溃用例同时打印完整 interaction 轨迹;转录文件只反映本次执行(过滤运行会以命中的用例整体覆盖文件)。 +- `-Dcorpus.output.dir=`:把输出重定向到指定目录(缺省写回场景目录;summary 默认落 `target/corpus/`)。 +- 无 LLM 时先跑结构自守卫:`mvn -pl a2a-t-corpus test -Dtest=TaskSelfGuardTest,NegotiationSelfGuardTest`。 + +## 用例 JSON 契约(v1,一手定义见 `schemas/`) + +```json +{ + "id": "TC00000001", "caseDimension": "01-意图清晰参数完整", "caseDesc": "标准专线业务中断投诉", + "caseCategory": "正常", + "input": [ + { "api": "generateTaskPromptFromText", + "args": { "text": "发生专线业务中断,接入端口名称为P781-……", "templateUri": "Task-T/network-layer/private-line-complaint/v1" } }, + { "api": "validateTaskPromptAndDataFilling", + "args": { "schema": { "…": "JSON Schema" }, "templateUri": "…", "promptText": { "$fromStep": 1, "$field": "promptText" } } } + ], + "expected": [ + { "result": "success", "data": {}, "error": { "errCode": "", "errMessage": "" } }, + { "result": "success", "data": { "accessPort": "P781-……" }, "error": { "errCode": "", "errMessage": "" } } + ] +} +``` + +- `id` 使用 `TC`(TestCase)前缀,如 `TC00000001`;id 在单个用例文件内唯一。 +- `input` / `expected` 为**等长数组**,顺序即执行序;步数不限,同一 API 可多次出现。 +- `expected.result`:`success` / `error`;`data` 为子集断言(可选 `dataExact` 收紧为全量相等);`error.errCode` 必须位于 SDK 错误码目录。 +- `{"$fromStep": N, "$field": "promptText"}` 按步序(1-based)引用更早步骤响应中的字段;显式字面值优先。 +- `caseCategory` 为开放枚举:`正常` / `异常` / `边界` / `模糊语义` …(仅作统计与组织维度,断言与之间无耦合)。 +- 加载为严格解析:未知键、缺期望、数组不等长、api 未注册、errCode 不在目录 → 加载期汇总报错。 +- 业务载荷(输入文本、schema 描述、期望槽位值)与用例元数据(`caseDimension` / `caseDesc` / `caseCategory`)保留中文,因为它们描述的是面向 zh-CN 提示词资源的业务用例;其余(工具、消息、代码)一律英文。 + +## 输出与指标 + +- 每场景:`output_result_from_text.json` / `output_result_from_data.json`,每个用例一条记录,其 interactions 交错记录 **API 步与其底层 LLM 调用**,携带完整请求/响应(异常步 dump 每个属性 + cause 链)、时延、Token——**完整输出,不截断**。 +- `a2a-t-corpus/target/corpus/summary_report_.json` + 控制台表:总体/按场景/按维度/按分类/按 API 的准确率(**95% 达标线标注**)、时延 p50/p95、Token 合计。 + +## 工具闭环 + +```bash +python a2a-t-corpus/tools/inputCsvToJson.py --template --out my-cases.csv # 用例设计表(含一行示例) +# 填表后转换(默认开启产物结构校验) +python a2a-t-corpus/tools/inputCsvToJson.py --csv my-cases.csv \ + --out a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/<场景>/input_case_from_text.json +# 跑套件后审视(一行=一个用例,含每步完整请求/响应) +python a2a-t-corpus/tools/outputJsonToCsv.py \ + --json a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/<场景>/output_result_from_text.json \ + --out review.csv +# 可选:把 input JSON 回填为设计表 +python a2a-t-corpus/tools/inputCsvToJson.py --reverse --csv --out back.csv +``` + +各平台直接运行 Python 脚本;`tools/*.sh` 为 bash 薄包装(Git Bash / WSL / Linux / macOS),参数 1:1 透传,把上面命令中的 `python a2a-t-corpus/tools/<名称>.py` 换成 `tools/<名称>.sh` 即可: + +```bash +tools/inputCsvToJson.sh --template --out my-cases.csv +tools/inputCsvToJson.sh --csv my-cases.csv --out <场景>/input_case_from_text.json +tools/outputJsonToCsv.sh --json <场景>/output_result_from_text.json --out review.csv +tools/inputCsvToJson.sh --reverse --csv --out back.csv +``` + +两个 sh 脚本**不是双击直接运行的**:双击启动时没有参数,只会打印用法说明(且终端可能立刻关闭)。请在终端中带参数运行;没有 bash 环境时直接 `python a2a-t-corpus/tools/...` 运行 Python 脚本。脚本会从终端 PATH 中查找 `python3`(找不到则回退 `python`),两者都缺失时给出明确报错。 + +## 新增场景(零 Java 改动) + +新建 `suites/<扩展>/resources/<场景>/` 并放入两个 input JSON 即可——`@TestFactory` 套件运行时扫描发现场景。结构门禁先用对应扩展的 `*SelfGuardTest`(无 LLM)跑一遍。 + +## 后续阶段(参考场景稳定后) + +Notification-T / Authorization-T:新增 `suites/<扩展>/` 套件与对应 `resources/`,在 `ApiRegistry` 登记该扩展 facade 方法,框架与工具链原样复用。 \ No newline at end of file diff --git a/a2a-t-corpus/env.example b/a2a-t-corpus/env.example new file mode 100644 index 00000000..ae4073ee --- /dev/null +++ b/a2a-t-corpus/env.example @@ -0,0 +1,31 @@ +# Corpus test LLM runtime +# Uses the same A2AT_LLM_* key names as the project-root env.example, so the corpus +# template is a tuned subset of the root one. Copy this file to .env and fill in the +# required entries; extra keys copied over from the root template are ignored. + +# LLM runtime +# Selects the LLM provider used by the shared client. Only "openai" is supported. +A2AT_LLM_PROVIDER=openai +# Selects the default model name for the configured provider. +A2AT_LLM_MODEL= +# API key for the configured LLM provider. +A2AT_LLM_API_KEY= +# Base URL for the configured LLM provider. +A2AT_LLM_BASE_URL= +# Maximum number of completion tokens. Validation outputs can exceed 2k tokens and +# reasoning models may consume the budget with reasoning content; keep this ample. +A2AT_LLM_MAX_TOKENS=102400 +# Default sampling temperature. +A2AT_LLM_TEMPERATURE=0 +# Request timeout in seconds. +A2AT_LLM_TIMEOUT_SECONDS=300 +# Maximum number of attempts for retryable LLM steps (range 1-10, default 3). +A2AT_LLM_MAX_ATTEMPTS=1 +# Whether to print the full LLM request and response payloads (no truncation). Defaults to +# false. WARNING: enabling this prints detailed LLM interaction content, which may expose +# sensitive information and consume large amounts of log space; use ONLY during project +# debugging and keep it disabled in production. +# Summary logs (timestamp, token usage, elapsed time) are recorded at DEBUG level whenever +# the dedicated logger net.openan.a2at.sdk.llm.call is enabled for DEBUG; where the lines +# are printed is decided by the embedding application's logging framework. +A2AT_LLM_DETAIL_LOG_ENABLED=false \ No newline at end of file diff --git a/a2a-t-corpus/pom.xml b/a2a-t-corpus/pom.xml new file mode 100644 index 00000000..fc45979b --- /dev/null +++ b/a2a-t-corpus/pom.xml @@ -0,0 +1,66 @@ + + 4.0.0 + + + net.openan.a2a-t.sdk + a2a-t-parent + ${revision} + + + a2a-t-corpus + jar + + a2a-t Corpus + Accuracy verification corpus suite for the a2a-t Java SDK: data-driven workflow cases executed against a real LLM; excluded from packaging and CI. + + + true + true + true + + + + + net.openan.a2a-t.sdk + a2a-t-client + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-server + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-negotiation + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-resources + ${project.version} + test + + + org.junit.jupiter + junit-jupiter + test + + + + + + + src/test/java + + **/resources/** + + + + src/test/resources + + + + \ No newline at end of file diff --git a/a2a-t-corpus/schemas/input-case.schema.json b/a2a-t-corpus/schemas/input-case.schema.json new file mode 100644 index 00000000..baf585d1 --- /dev/null +++ b/a2a-t-corpus/schemas/input-case.schema.json @@ -0,0 +1,99 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openan.net/a2a-t-sdk/corpus/schemas/input-case.schema.json", + "title": "A2A-T corpus input case format", + "description": "First-hand definition of the data-driven workflow case consumed by the corpus engine. One file is a JSON array of case records. Each case declares an ordered array of SDK API steps (input) and one expectation per step (expected, aligned by index). Keys are strict: unknown keys are load-time errors.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "caseDimension", "caseDesc", "caseCategory", "input", "expected"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Case primary key, unique within one scenario." + }, + "caseDimension": { + "type": "string", + "minLength": 1, + "description": "Accuracy dimension this case exercises, the aggregation key for accuracy statistics." + }, + "caseDesc": { + "type": "string", + "minLength": 1, + "description": "One business-facing sentence describing the case." + }, + "caseCategory": { + "type": "string", + "minLength": 1, + "description": "Open enumeration of case categories, e.g. 正常 / 异常 / 边界 / 模糊语义 / ... ; statistics aggregate by the raw value." + }, + "input": { + "type": "array", + "minItems": 1, + "description": "Ordered SDK API steps; array order is the execution order; the same API may appear multiple times.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["api", "args"], + "properties": { + "api": { + "type": "string", + "minLength": 1, + "description": "Registered SDK facade method name, e.g. generateTaskPromptFromText." + }, + "args": { + "type": "object", + "description": "Request arguments for the step. Literal values take precedence; a {\"$fromStep\": N, \"$field\": \"a.b\"} object copies the field from the response of step N (1-based, earlier steps only)." + } + } + } + }, + "expected": { + "type": "array", + "minItems": 1, + "description": "Per-step expectations, aligned by index with input.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["result", "error"], + "properties": { + "result": { + "type": "string", + "enum": ["success", "error"], + "description": "Expected step outcome." + }, + "data": { + "type": "object", + "description": "Subset assertion against the step response payload: every listed key must equal. Applies to the payload root, or to its nested data map when the payload carries one (FilledParamData)." + }, + "dataExact": { + "type": "boolean", + "description": "When true, the asserted map must contain exactly the listed keys (no extras).", + "default": false + }, + "error": { + "type": "object", + "additionalProperties": false, + "properties": { + "errCode": { + "type": "string", + "description": "Exact SDK error catalog code expected when result is error; may be empty when the code is not asserted." + }, + "errMessage": { + "type": "string", + "description": "Exact error message; when absent only non-empty is required." + }, + "errMessageContains": { + "type": "string", + "description": "Substring the error message must contain." + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/a2a-t-corpus/schemas/output-result.schema.json b/a2a-t-corpus/schemas/output-result.schema.json new file mode 100644 index 00000000..ba554344 --- /dev/null +++ b/a2a-t-corpus/schemas/output-result.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openan.net/a2a-t-sdk/corpus/schemas/output-result.schema.json", + "title": "A2A-T corpus output result format", + "description": "Execution transcript written by the corpus engine for one scenario file. One file is a JSON array of result records; each record echoes the case metadata, aggregates per-case totals, and lists the full interaction transcript (SDK API steps interleaved with their underlying LLM calls). Content is always complete, never truncated.", + "type": "array", + "items": { + "type": "object", + "required": ["id", "caseDimension", "caseDesc", "caseCategory", "result", "totalTime", "totalInputToken", "totalOutputToken", "totalToken", "output"], + "properties": { + "id": { "type": "string" }, + "caseDimension": { "type": "string" }, + "caseDesc": { "type": "string" }, + "caseCategory": { "type": "string" }, + "totalTime": { "type": "integer", "description": "Case total duration in milliseconds." }, + "totalInputToken": { "type": "integer" }, + "totalOutputToken": { "type": "integer" }, + "totalToken": { "type": "integer" }, + "result": { + "type": "string", + "enum": ["success", "failure", "error"], + "description": "success: every step matched its expectation; failure: at least one assertion did not match; error: the engine crashed on a step." + }, + "failReason": { + "type": "string", + "description": "One-sentence pointer to the first unmet assertion, e.g. step2.data.accessPort." + }, + "output": { + "type": "object", + "required": ["interactions"], + "properties": { + "interactions": { + "type": "array", + "description": "Globally numbered transcript mixing SDK API steps (type = API method name) with their underlying LLM calls (type = LLM).", + "items": { + "type": "object", + "required": ["step", "type", "result", "duration_ms", "request", "response", "inputToken", "outputToken"], + "properties": { + "step": { "type": "string" }, + "type": { "type": "string" }, + "result": { "type": "string", "enum": ["success", "error", "skipped"] }, + "duration_ms": { "type": "integer" }, + "request": { "type": "object" }, + "response": { "type": "object" }, + "inputToken": { "type": "integer" }, + "outputToken": { "type": "integer" } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/CorpusWorkFlowSuite.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/CorpusWorkFlowSuite.java new file mode 100644 index 00000000..5d9d6878 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/CorpusWorkFlowSuite.java @@ -0,0 +1,191 @@ +package net.openan.a2at.sdk.corpus.engine; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.TestInstance; +import net.openan.a2at.sdk.corpus.engine.discover.ScenarioScanner; +import net.openan.a2at.sdk.corpus.engine.engine.WorkflowEngine; +import net.openan.a2at.sdk.corpus.engine.llm.RecordingMeteredLlmClient; +import net.openan.a2at.sdk.corpus.engine.loader.CaseFileLoader; +import net.openan.a2at.sdk.corpus.engine.loader.InputCase; +import net.openan.a2at.sdk.corpus.engine.registry.ApiRegistry; +import net.openan.a2at.sdk.corpus.engine.registry.SdkRuntimeAssembler; +import net.openan.a2at.sdk.corpus.engine.report.ResultAggregator; +import net.openan.a2at.sdk.corpus.engine.report.TranscriptWriter; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Base class of every {@code -T} workflow suite. A concrete suite declares only its extension folder, its workflow + * type (from_text / from_data), the consumed case file and the runtime kind; scenario discovery, case loading, + * execution, expectation comparison and transcript/summary writing are all inherited. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public abstract class CorpusWorkFlowSuite { + + public static final String INPUT_CASE_FROM_TEXT = "input_case_from_text.json"; + + public static final String INPUT_CASE_FROM_DATA = "input_case_from_data.json"; + + public static final String FLOW_FROM_TEXT = "from_text"; + + public static final String FLOW_FROM_DATA = "from_data"; + + public static final String API_GENERATE_TASK_FROM_TEXT = "generateTaskPromptFromText"; + + public static final String API_GENERATE_TASK_FROM_DATA = "generateTaskPromptFromDataWithSchema"; + + public static final String API_VALIDATE_TASK = "validateTaskPromptAndDataFilling"; + + /** Negotiation-T API names, used for load-time membership checks. */ + public static final String API_GENERATE_NEGOTIATION_PROPOSE_FROM_TEXT = "generateNegotiationProposePromptFromText"; + public static final String API_GENERATE_NEGOTIATION_ACCEPT_FROM_TEXT = "generateNegotiationAcceptPromptFromText"; + public static final String API_GENERATE_NEGOTIATION_REJECT_FROM_TEXT = "generateNegotiationRejectPromptFromText"; + public static final String API_GENERATE_NEGOTIATION_ABORT_FROM_TEXT = "generateNegotiationAbortPromptFromText"; + public static final String API_GENERATE_NEGOTIATION_PROPOSE_FROM_DATA = "generateNegotiationProposePromptFromData"; + public static final String API_GENERATE_NEGOTIATION_ACCEPT_FROM_DATA = "generateNegotiationAcceptPromptFromData"; + public static final String API_GENERATE_NEGOTIATION_REJECT_FROM_DATA = "generateNegotiationRejectPromptFromData"; + public static final String API_GENERATE_NEGOTIATION_ABORT_FROM_DATA = "generateNegotiationAbortPromptFromData"; + public static final String API_VALIDATE_NEGOTIATION_PROPOSE = "validateProposePromptAndDataFilling"; + public static final String API_VALIDATE_NEGOTIATION_ACCEPT = "validateAcceptPromptAndDataFilling"; + public static final String API_VALIDATE_NEGOTIATION_REJECT = "validateRejectPromptAndDataFilling"; + public static final String API_VALIDATE_NEGOTIATION_ABORT = "validateAbortPromptAndDataFilling"; + + private static volatile SdkRuntimeAssembler.Runtime taskRuntimeSingleton; + + private static volatile SdkRuntimeAssembler.NegotiationRuntime negotiationRuntimeSingleton; + + private final List results = new ArrayList<>(); + + private final Map scenarioDirs = new LinkedHashMap<>(); + + /** Extension resource folder under the suites package, e.g. {@code task} or {@code negotiation}. */ + protected abstract String extensionFolder(); + + /** Consumed case file, e.g. {@code input_case_from_text.json}. */ + protected abstract String inputFileName(); + + /** Flow type used for the transcript and summary file names: {@code from_text} or {@code from_data}. */ + protected abstract String flowType(); + + /** Runtime kind: {@code "task"} (default) or {@code "negotiation"}. */ + protected String runtimeKind() { + return "task"; + } + + /** API names for load-time membership checks. */ + protected abstract Set apiNames(); + + @TestFactory + Stream corpusCases() { + ApiRegistry registry; + if ("negotiation".equals(runtimeKind())) { + SdkRuntimeAssembler.NegotiationRuntime rt = negotiationRuntime(); + registry = SdkRuntimeAssembler.buildNegotiationRegistry(rt); + } else { + SdkRuntimeAssembler.Runtime rt = taskRuntime(); + registry = SdkRuntimeAssembler.buildTaskRegistry(rt); + } + WorkflowEngine engine = new WorkflowEngine(registry, registryRecorder()); + + List scenarios = ScenarioScanner.filterScenarios( + ScenarioScanner.discover(extensionFolder(), inputFileName()), + System.getProperty("corpus.scenario")); + String caseFilter = System.getProperty("case.filter"); + + List tests = new ArrayList<>(); + for (ScenarioScanner.Scenario scenario : scenarios) { + List cases = + new CaseFileLoader().load(scenario.inputFile(inputFileName()), registry.apiNames()); + scenarioDirs.put(scenario.name(), scenario.sourceDir()); + for (InputCase inputCase : cases) { + if (!ScenarioScanner.matchesCaseFilter(caseFilter, inputCase.id())) { + continue; + } + tests.add(DynamicTest.dynamicTest(scenario.name() + "/" + inputCase.id(), () -> { + WorkflowEngine.CaseResult result = engine.run(scenario.name(), inputCase); + results.add(result); + ResultAggregator.printCaseLine(result); + assertEquals( + "success", + result.verdict(), + () -> result.failReason().isEmpty() + ? "case verdict=" + result.verdict() + ", see the output JSON" + : result.failReason()); + })); + } + } + return tests.stream(); + } + + @AfterAll + void writeTranscripts() { + for (Map.Entry> entry : groupByScenario(results).entrySet()) { + Path resultFile = TranscriptWriter.writeScenarioOutput( + flowType(), entry.getKey(), scenarioDirs.get(entry.getKey()), entry.getValue()); + System.out.println("Output: " + resultFile); + } + Path summaryFile = ResultAggregator.writeSummary(flowType(), results); + System.out.println("Summary: " + summaryFile); + } + + private static synchronized SdkRuntimeAssembler.Runtime taskRuntime() { + if (taskRuntimeSingleton == null) { + taskRuntimeSingleton = SdkRuntimeAssembler.taskRuntime(); + } + return taskRuntimeSingleton; + } + + private static synchronized SdkRuntimeAssembler.NegotiationRuntime negotiationRuntime() { + if (negotiationRuntimeSingleton == null) { + negotiationRuntimeSingleton = SdkRuntimeAssembler.negotiationRuntime(); + } + return negotiationRuntimeSingleton; + } + + private RecordingMeteredLlmClient registryRecorder() { + if ("negotiation".equals(runtimeKind())) { + return negotiationRuntime().recorder(); + } + return taskRuntime().recorder(); + } + + private static Map> groupByScenario( + List allResults) { + Map> grouped = new LinkedHashMap<>(); + for (WorkflowEngine.CaseResult result : allResults) { + grouped.computeIfAbsent(result.scenario(), ignored -> new ArrayList<>()).add(result); + } + return grouped; + } + + /** Task-T phase-1 API names, used for load-time membership checks without touching the LLM runtime. */ + public static Set taskApiNames() { + return Set.of(API_GENERATE_TASK_FROM_TEXT, API_GENERATE_TASK_FROM_DATA, API_VALIDATE_TASK); + } + + /** Negotiation-T phase-1 API names, used for load-time membership checks without touching the LLM runtime. */ + public static Set negotiationApiNames() { + return Set.of( + API_GENERATE_NEGOTIATION_PROPOSE_FROM_TEXT, + API_GENERATE_NEGOTIATION_ACCEPT_FROM_TEXT, + API_GENERATE_NEGOTIATION_REJECT_FROM_TEXT, + API_GENERATE_NEGOTIATION_ABORT_FROM_TEXT, + API_GENERATE_NEGOTIATION_PROPOSE_FROM_DATA, + API_GENERATE_NEGOTIATION_ACCEPT_FROM_DATA, + API_GENERATE_NEGOTIATION_REJECT_FROM_DATA, + API_GENERATE_NEGOTIATION_ABORT_FROM_DATA, + API_VALIDATE_NEGOTIATION_PROPOSE, + API_VALIDATE_NEGOTIATION_ACCEPT, + API_VALIDATE_NEGOTIATION_REJECT, + API_VALIDATE_NEGOTIATION_ABORT); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/assertion/ExpectationComparator.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/assertion/ExpectationComparator.java new file mode 100644 index 00000000..c1d87026 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/assertion/ExpectationComparator.java @@ -0,0 +1,130 @@ +package net.openan.a2at.sdk.corpus.engine.assertion; + +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import net.openan.a2at.sdk.corpus.engine.engine.StepOutcome; +import net.openan.a2at.sdk.corpus.engine.loader.ExpectedStep; + +/** + * Compares one step outcome against its expectation. + * + *
    + *
  • {@code result} matches exactly ({@code success} / {@code error});
  • + *
  • on success, {@code data} is a subset assertion: every listed key must equal; the asserted map is the payload + * root, or its nested {@code data} map when the payload carries one (FilledParamData). {@code dataExact} tightens the + * match to exactly the listed keys;
  • + *
  • on error, {@code error.errCode} matches the SDK catalog code exactly, {@code error.errMessage} matches exactly + * when present (otherwise any non-empty message passes) and {@code error.errMessageContains} is a substring test.
  • + *
+ */ +public final class ExpectationComparator { + + /** Comparison verdict: failed flag plus one-sentence pointer-style message. */ + public record StepAssertion(boolean passed, String failMessage) { + + static StepAssertion pass() { + return new StepAssertion(true, ""); + } + + static StepAssertion fail(String message) { + return new StepAssertion(false, message); + } + } + + private ExpectationComparator() {} + + public static StepAssertion compare(ExpectedStep expected, StepOutcome outcome) { + if ("skipped".equals(outcome.actualResult())) { + return StepAssertion.fail("step" + outcome.stepNo() + " was skipped because a previous step assertion failed"); + } + if ("success".equals(expected.result())) { + return compareSuccess(expected, outcome); + } + return compareError(expected, outcome); + } + + private static StepAssertion compareSuccess(ExpectedStep expected, StepOutcome outcome) { + if (!"success".equals(outcome.actualResult())) { + String actual = outcome.crashed() + ? "engine crash(" + exceptionName(outcome) + ")" + : "error(" + (outcome.errCode() == null ? "no error code" : outcome.errCode()) + ")"; + return StepAssertion.fail("step" + outcome.stepNo() + " expected success, actual " + actual); + } + Map target = assertionTarget(outcome.payload()); + for (Map.Entry entry : expected.data().entrySet()) { + String key = entry.getKey(); + Object expectedValue = entry.getValue(); + Object actualValue = target.get(key); + if (!target.containsKey(key) || !Objects.equals(expectedValue, actualValue)) { + return StepAssertion.fail("step" + outcome.stepNo() + ".data." + key + " expected " + + expectedValue + " actual " + actualValue); + } + } + if (expected.dataExact() && !target.keySet().equals(expected.data().keySet())) { + Set onlyActual = new LinkedHashSet<>(target.keySet()); + onlyActual.removeAll(expected.data().keySet()); + Set onlyExpected = new LinkedHashSet<>(expected.data().keySet()); + onlyExpected.removeAll(target.keySet()); + return StepAssertion.fail("step" + outcome.stepNo() + " data exact assertion failed (dataExact): extra=" + + onlyActual + " missing=" + onlyExpected); + } + return StepAssertion.pass(); + } + + private static StepAssertion compareError(ExpectedStep expected, StepOutcome outcome) { + if (!"error".equals(outcome.actualResult())) { + return StepAssertion.fail("step" + outcome.stepNo() + " expected error" + + (expected.errCode().isBlank() ? "" : "(" + expected.errCode() + ")") + ", actual success"); + } + if (outcome.crashed()) { + return StepAssertion.fail("step" + outcome.stepNo() + " expected error(" + + expected.errCode() + "), actual engine crash(" + exceptionName(outcome) + ")"); + } + if (!expected.errCode().isBlank() && !expected.errCode().equals(outcome.errCode())) { + return StepAssertion.fail("step" + outcome.stepNo() + " errCode expected " + + expected.errCode() + " actual " + outcome.errCode()); + } + if (!expected.errMessage().isBlank() && !expected.errMessage().equals(outcome.errMessage())) { + return StepAssertion.fail("step" + outcome.stepNo() + " errMessage expected [" + + expected.errMessage() + "] actual [" + outcome.errMessage() + "]"); + } + if (isBlank(outcome.errMessage())) { + return StepAssertion.fail("step" + outcome.stepNo() + " expected a non-empty error message, actual is empty"); + } + if (!expected.errMessageContains().isBlank() + && (outcome.errMessage() == null + || !outcome.errMessage().contains(expected.errMessageContains()))) { + return StepAssertion.fail("step" + outcome.stepNo() + " errMessage does not contain [" + + expected.errMessageContains() + "], actual [" + outcome.errMessage() + "]"); + } + return StepAssertion.pass(); + } + + /** + * Returns the map the data subset assertion runs against: the nested {@code data} map of a FilledParamData-like + * payload, or the payload root. + */ + private static Map assertionTarget(Map payload) { + Object data = payload.get("data"); + if (data instanceof Map) { + @SuppressWarnings("unchecked") + Map nested = (Map) data; + return nested; + } + return payload; + } + + private static String exceptionName(StepOutcome outcome) { + if (outcome.errorResponse() == null) { + return "unknown error"; + } + Object name = outcome.errorResponse().get("exception"); + return name == null ? "unknown error" : String.valueOf(name); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/config/CorpusEnvConfig.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/config/CorpusEnvConfig.java new file mode 100644 index 00000000..48291c32 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/config/CorpusEnvConfig.java @@ -0,0 +1,296 @@ +package net.openan.a2at.sdk.corpus.engine.config; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Unified LLM configuration for the accuracy verification corpus. + * + *

Configuration keys follow the project-root {@code env.example} naming (the {@code A2AT_LLM_*} family), so the + * corpus {@code env.example} is a tuned subset of the root template and one set of names serves both. One shared + * configuration serves every -T extension suite: values are resolved in the order system properties + * ({@code a2at.llm.*}) > the module-root {@code .env} file ({@code A2AT_LLM_*}) > process environment + * variables ({@code A2AT_LLM_*}). + * + *

This module must run against a real LLM; a missing or invalid configuration therefore fails fast with an + * actionable error instead of producing silently wrong results. CI excludes this module entirely, so the fail-fast + * never disturbs the pipeline. + */ +public final class CorpusEnvConfig { + + /** Environment-file keys (also accepted as process environment variables); names mirror the root {@code env.example}. */ + static final String ENV_PROVIDER = "A2AT_LLM_PROVIDER"; + + static final String ENV_BASE_URL = "A2AT_LLM_BASE_URL"; + + static final String ENV_API_KEY = "A2AT_LLM_API_KEY"; + + static final String ENV_MODEL = "A2AT_LLM_MODEL"; + + static final String ENV_TEMPERATURE = "A2AT_LLM_TEMPERATURE"; + + static final String ENV_TIMEOUT_SECONDS = "A2AT_LLM_TIMEOUT_SECONDS"; + + static final String ENV_MAX_ATTEMPTS = "A2AT_LLM_MAX_ATTEMPTS"; + + static final String ENV_MAX_TOKENS = "A2AT_LLM_MAX_TOKENS"; + + /** System-property keys, taking precedence over the {@code .env} file and environment variables. */ + private static final String PROP_PROVIDER = "a2at.llm.provider"; + + private static final String PROP_BASE_URL = "a2at.llm.base.url"; + + private static final String PROP_API_KEY = "a2at.llm.api.key"; + + private static final String PROP_MODEL = "a2at.llm.model"; + + private static final String PROP_TEMPERATURE = "a2at.llm.temperature"; + + private static final String PROP_TIMEOUT_SECONDS = "a2at.llm.timeout.seconds"; + + private static final String PROP_MAX_ATTEMPTS = "a2at.llm.max.attempts"; + + private static final String PROP_MAX_TOKENS = "a2at.llm.max.tokens"; + + private static final String DEFAULT_PROVIDER = "openai"; + + private static final int DEFAULT_MAX_ATTEMPTS = 3; + + private static CorpusEnvConfig instance; + + private final String provider; + + private final String baseUrl; + + private final String apiKey; + + private final String model; + + private final Double temperature; + + private final Double timeoutSeconds; + + private final int maxAttempts; + + private final Integer maxTokens; + + private CorpusEnvConfig( + String provider, + String baseUrl, + String apiKey, + String model, + Double temperature, + Double timeoutSeconds, + int maxAttempts, + Integer maxTokens) { + this.provider = provider; + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.model = model; + this.temperature = temperature; + this.timeoutSeconds = timeoutSeconds; + this.maxAttempts = maxAttempts; + this.maxTokens = maxTokens; + } + + public static synchronized CorpusEnvConfig load() { + if (instance == null) { + instance = create(); + } + return instance; + } + + private static CorpusEnvConfig create() { + Map envFileValues = readEnvFile(); + String provider = resolve(ENV_PROVIDER, PROP_PROVIDER, envFileValues); + if (isBlank(provider)) { + provider = DEFAULT_PROVIDER; + } + String baseUrl = resolve(ENV_BASE_URL, PROP_BASE_URL, envFileValues); + String apiKey = resolve(ENV_API_KEY, PROP_API_KEY, envFileValues); + String model = resolve(ENV_MODEL, PROP_MODEL, envFileValues); + + StringBuilder problems = new StringBuilder(); + if (!DEFAULT_PROVIDER.equals(provider.trim())) { + problems.append("\n- ") + .append(ENV_PROVIDER) + .append(" must be \"") + .append(DEFAULT_PROVIDER) + .append("\" (currently \"") + .append(provider.trim()) + .append("\")"); + } + if (isBlank(baseUrl)) { + problems.append("\n- ").append(ENV_BASE_URL).append(" (or -D").append(PROP_BASE_URL) + .append(") is not configured"); + } + if (isBlank(apiKey)) { + problems.append("\n- ").append(ENV_API_KEY).append(" (or -D").append(PROP_API_KEY) + .append(") is not configured"); + } + if (isBlank(model)) { + problems.append("\n- ").append(ENV_MODEL).append(" (or -D").append(PROP_MODEL) + .append(") is not configured"); + } + if (problems.length() > 0) { + throw new IllegalStateException( + "a2a-t-corpus requires a real LLM configuration; invalid or missing values:" + + problems + + "\nCopy a2a-t-corpus/env.example to a2a-t-corpus/.env and fill in the required" + + " entries (or override them with system properties)." + + "\nCI pipelines should exclude this module with -pl '!a2a-t-corpus'."); + } + + return new CorpusEnvConfig( + provider.trim(), + baseUrl.trim(), + apiKey.trim(), + model.trim(), + parseOptionalDouble(ENV_TEMPERATURE, PROP_TEMPERATURE, envFileValues), + parseOptionalDouble(ENV_TIMEOUT_SECONDS, PROP_TIMEOUT_SECONDS, envFileValues), + parseMaxAttempts(ENV_MAX_ATTEMPTS, PROP_MAX_ATTEMPTS, envFileValues), + parseOptionalInteger(ENV_MAX_TOKENS, PROP_MAX_TOKENS, envFileValues)); + } + + private static Map readEnvFile() { + Path envFile = envFile().toAbsolutePath().normalize(); + Map values = new LinkedHashMap<>(); + if (!Files.isRegularFile(envFile)) { + return values; + } + try { + for (String rawLine : Files.readAllLines(envFile, StandardCharsets.UTF_8)) { + String line = rawLine.strip(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + int equals = line.indexOf('='); + if (equals <= 0) { + continue; + } + String key = line.substring(0, equals).strip(); + String value = stripQuotes(line.substring(equals + 1).strip()); + values.put(key, value); + } + } catch (IOException error) { + throw new IllegalStateException("Failed to read " + envFile + ": " + error.getMessage(), error); + } + return values; + } + + private static String stripQuotes(String value) { + if (value.length() >= 2) { + char first = value.charAt(0); + char last = value.charAt(value.length() - 1); + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + return value.substring(1, value.length() - 1); + } + } + return value; + } + + private static String resolve(String envKey, String propKey, Map envFileValues) { + String fromProperty = System.getProperty(propKey); + if (fromProperty != null && !fromProperty.isBlank()) { + return fromProperty.strip(); + } + String fromFile = envFileValues.get(envKey); + if (fromFile != null && !fromFile.isBlank()) { + return fromFile; + } + String fromEnvironment = System.getenv(envKey); + return fromEnvironment == null ? "" : fromEnvironment; + } + + private static Double parseOptionalDouble(String envKey, String propKey, Map envFileValues) { + String raw = resolve(envKey, propKey, envFileValues); + if (isBlank(raw)) { + return null; + } + try { + return Double.parseDouble(raw.trim()); + } catch (NumberFormatException error) { + throw new IllegalStateException(envKey + " must be numeric: '" + raw.trim() + "'", error); + } + } + + private static Integer parseOptionalInteger(String envKey, String propKey, Map envFileValues) { + String raw = resolve(envKey, propKey, envFileValues); + if (isBlank(raw)) { + return null; + } + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException error) { + throw new IllegalStateException(envKey + " must be an integer: '" + raw.trim() + "'", error); + } + } + + private static int parseMaxAttempts(String envKey, String propKey, Map envFileValues) { + String raw = resolve(envKey, propKey, envFileValues); + if (isBlank(raw)) { + return DEFAULT_MAX_ATTEMPTS; + } + int parsed; + try { + parsed = Integer.parseInt(raw.trim()); + } catch (NumberFormatException error) { + throw new IllegalStateException(envKey + " must be an integer between 1 and 10: '" + raw.trim() + "'", error); + } + if (parsed < 1 || parsed > 10) { + throw new IllegalStateException(envKey + " must be between 1 and 10: " + parsed); + } + return parsed; + } + + /** + * Returns the {@code .env} path as seen by the SDK builders, regardless of whether the file exists. + */ + public Path envPath() { + return envFile().toAbsolutePath().normalize(); + } + + private static Path envFile() { + return Path.of(System.getProperty("user.dir", "."), ".env"); + } + + public String provider() { + return provider; + } + + public String baseUrl() { + return baseUrl; + } + + public String apiKey() { + return apiKey; + } + + public String model() { + return model; + } + + public Double temperature() { + return temperature; + } + + public Double timeoutSeconds() { + return timeoutSeconds; + } + + public int maxAttempts() { + return maxAttempts; + } + + public Integer maxTokens() { + return maxTokens; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/discover/ScenarioScanner.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/discover/ScenarioScanner.java new file mode 100644 index 00000000..7502bb41 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/discover/ScenarioScanner.java @@ -0,0 +1,137 @@ +package net.openan.a2at.sdk.corpus.engine.discover; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.List; + +/** + * Discovers corpus scenarios by directory convention: a scenario is any directory under the extension's + * {@code resources/} folder that carries the requested case file ({@code input_case_from_text.json} or + * {@code input_case_from_data.json}). New scenarios therefore require zero Java changes. + * + *

Discovery prefers the source tree ({@code src/test/java/.../suites//resources/}), which is where Maven + * runs surefire ({@code user.dir} is the module base directory); it falls back to the test classpath for IDE runs. + */ +public final class ScenarioScanner { + + /** One discovered scenario: directory name plus writable source directory carrying the case files. */ + public record Scenario(String name, Path sourceDir) { + + public Path inputFile(String flowFileName) { + return sourceDir.resolve(flowFileName); + } + } + + private static final String RESOURCE_PATH_PREFIX = + "net/openan/a2at/sdk/corpus/suites/"; + + private ScenarioScanner() {} + + public static List discover(String extensionFolder, String flowFileName) { + List scenarios = new ArrayList<>(); + Path sourceTree = Path.of( + System.getProperty("user.dir", "."), + "src", + "test", + "java", + "net", + "openan", + "a2at", + "sdk", + "corpus", + "suites", + extensionFolder, + "resources") + .toAbsolutePath() + .normalize(); + if (Files.isDirectory(sourceTree)) { + collectFromDirectory(sourceTree, scenarios, flowFileName); + } + if (scenarios.isEmpty()) { + collectFromClasspath(extensionFolder, scenarios, flowFileName); + } + scenarios.sort(Comparator.comparing(Scenario::name)); + if (scenarios.isEmpty()) { + throw new IllegalStateException( + "No corpus scenario directories found: expected " + extensionFolder + "/resources//" + flowFileName + + " (neither the source tree " + sourceTree + " nor the test classpath matches)"); + } + return scenarios; + } + + public static List filterScenarios(List scenarios, String filter) { + if (filter == null || filter.isBlank()) { + return scenarios; + } + List filtered = new ArrayList<>(); + for (String pattern : filter.split(",")) { + PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern.strip()); + for (Scenario scenario : scenarios) { + if (matcher.matches(Path.of(scenario.name())) && !filtered.contains(scenario)) { + filtered.add(scenario); + } + } + } + return filtered; + } + + public static boolean matchesCaseFilter(String caseFilter, String caseId) { + if (caseFilter == null || caseFilter.isBlank()) { + return true; + } + PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + caseFilter.strip()); + return matcher.matches(Path.of(caseId)); + } + + private static void collectFromDirectory(Path sourceTree, List scenarios, String flowFileName) { + try (var entries = Files.list(sourceTree)) { + List directories = entries.filter(Files::isDirectory).toList(); + for (Path directory : directories) { + if (Files.isRegularFile(directory.resolve(flowFileName))) { + scenarios.add(new Scenario(directory.getFileName().toString(), directory)); + } + } + } catch (IOException error) { + throw new IllegalStateException("Failed to scan the scenario directory: " + sourceTree, error); + } + } + + private static void collectFromClasspath( + String extensionFolder, List scenarios, String flowFileName) { + String resourceRoot = RESOURCE_PATH_PREFIX + extensionFolder + "/resources"; + try { + ClassLoader loader = Thread.currentThread().getContextClassLoader(); + Enumeration roots = loader.getResources(resourceRoot); + while (roots.hasMoreElements()) { + URL root = roots.nextElement(); + if (!"file".equals(root.getProtocol())) { + continue; + } + try { + File rootFile = java.nio.file.Paths.get(root.toURI()).toFile(); + File[] children = rootFile.listFiles(File::isDirectory); + if (children == null) { + continue; + } + for (File child : children) { + if (new File(child, flowFileName).isFile()) { + scenarios.add(new Scenario(child.getName(), child.toPath().toAbsolutePath())); + } + } + } catch (Exception ignored) { + // unreadable root entry; ignore and continue scanning other roots + } + } + } catch (IOException error) { + throw new IllegalStateException("Failed to scan the test classpath for scenario directories: " + resourceRoot, error); + } + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/FromStepResolver.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/FromStepResolver.java new file mode 100644 index 00000000..b3d4cd1c --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/FromStepResolver.java @@ -0,0 +1,98 @@ +package net.openan.a2at.sdk.corpus.engine.engine; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Resolves {@code {"$fromStep": N, "$field": "a.b"}} references inside step arguments before the step runs. + * + *

{@code $fromStep} is the 1-based index of an earlier step whose serialized response payload is navigated with the + * dot-separated {@code $field} path. Literal values always take precedence; a reference may appear at any nesting + * depth of the arguments map. + */ +public final class FromStepResolver { + + private FromStepResolver() {} + + /** + * Resolves every {@code $fromStep} reference in the arguments of step {@code stepNumber}. + * + * @param args step arguments, possibly carrying references + * @param stepNumber 1-based number of the step being resolved (references must target earlier steps) + * @param priorPayloads serialized response payloads of the preceding steps, aligned by step index + * @return a deep copy of the arguments with all references replaced + */ + public static Map resolve( + Map args, int stepNumber, List> priorPayloads) { + return (Map) resolveValue(args, stepNumber, priorPayloads); + } + + private static Object resolveValue( + Object value, int stepNumber, List> priorPayloads) { + if (value instanceof Map rawMap) { + Map map = rawMap; + if (map.containsKey("$fromStep") || map.containsKey("$field")) { + return resolveReference(map, stepNumber, priorPayloads); + } + Map resolved = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + resolved.put( + String.valueOf(entry.getKey()), + resolveValue(entry.getValue(), stepNumber, priorPayloads)); + } + return resolved; + } + if (value instanceof List list) { + List resolved = new ArrayList<>(); + for (Object entry : list) { + resolved.add(resolveValue(entry, stepNumber, priorPayloads)); + } + return resolved; + } + return value; + } + + private static Object resolveReference( + Map reference, int stepNumber, List> priorPayloads) { + if (reference.size() != 2 || !reference.containsKey("$fromStep") || !reference.containsKey("$field")) { + throw new IllegalArgumentException( + "step" + stepNumber + " $fromStep reference must contain exactly the two keys $fromStep and $field: " + reference); + } + Object rawStep = reference.get("$fromStep"); + Object rawField = reference.get("$field"); + if (!(rawStep instanceof Number) || !(rawField instanceof String field) || field.isBlank()) { + throw new IllegalArgumentException("step" + stepNumber + + " invalid $fromStep reference ($fromStep must be a positive integer, $field a non-blank string): " + reference); + } + int fromStep = ((Number) rawStep).intValue(); + if (fromStep < 1 || fromStep >= stepNumber || fromStep - 1 >= priorPayloads.size()) { + throw new IllegalArgumentException("step" + stepNumber + " $fromStep=" + fromStep + + " must reference an earlier, already-executed step (1.." + (stepNumber - 1) + ")"); + } + Map payload = priorPayloads.get(fromStep - 1); + if (payload == null) { + throw new IllegalArgumentException("step" + stepNumber + " $fromStep=" + fromStep + + " references a step without a success payload (it failed or was skipped)"); + } + return navigate(payload, field, fromStep, stepNumber); + } + + private static Object navigate( + Map payload, String path, int fromStep, int stepNumber) { + Object current = payload; + for (String segment : path.split("\\.")) { + if (!(current instanceof Map map) || !map.containsKey(segment)) { + throw new IllegalArgumentException("step" + stepNumber + " reference $fromStep=" + fromStep + + " $field=" + path + " failed to resolve: field " + segment + " does not exist in the referenced step payload"); + } + current = map.get(segment); + } + if (current == null) { + throw new IllegalArgumentException("step" + stepNumber + " reference $fromStep=" + fromStep + + " $field=" + path + " resolved to null, which cannot be used as a step argument"); + } + return current; + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/StepOutcome.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/StepOutcome.java new file mode 100644 index 00000000..fe27c5db --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/StepOutcome.java @@ -0,0 +1,62 @@ +package net.openan.a2at.sdk.corpus.engine.engine; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.corpus.engine.llm.RecordingMeteredLlmClient; + +/** + * Result of executing one input step. + * + * @param stepNo 1-based step number + * @param api executed API method name + * @param actualResult {@code success} / {@code error} / {@code skipped} + * @param request resolved request arguments actually passed to the SDK + * @param payload serialized response payload on success + * @param errorResponse serialized exception dump on error + * @param errCode machine-readable error code; {@code null} when the failure is not an SDK coded error + * @param errMessage failure message + * @param durationMs step wall-clock duration + * @param llmCalls LLM invocations recorded during this step + * @param crashed true when the step failed with a non-coded error (engine-level crash) + */ +public record StepOutcome( + int stepNo, + String api, + String actualResult, + Map request, + Map payload, + Map errorResponse, + String errCode, + String errMessage, + long durationMs, + List llmCalls, + boolean crashed) { + + public static StepOutcome success( + int stepNo, + String api, + Map request, + Map payload, + long durationMs, + List llmCalls) { + return new StepOutcome(stepNo, api, "success", request, payload, null, null, null, durationMs, llmCalls, false); + } + + public static StepOutcome error( + int stepNo, + String api, + Map request, + Map errorResponse, + String errCode, + String errMessage, + boolean crashed, + long durationMs, + List llmCalls) { + return new StepOutcome( + stepNo, api, "error", request, null, errorResponse, errCode, errMessage, durationMs, llmCalls, crashed); + } + + public static StepOutcome skipped(int stepNo, String api, Map request, long durationMs) { + return new StepOutcome(stepNo, api, "skipped", request, null, null, null, null, durationMs, List.of(), false); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/WorkflowEngine.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/WorkflowEngine.java new file mode 100644 index 00000000..781e7a7f --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/engine/WorkflowEngine.java @@ -0,0 +1,216 @@ +package net.openan.a2at.sdk.corpus.engine.engine; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.corpus.engine.assertion.ExpectationComparator; +import net.openan.a2at.sdk.corpus.engine.llm.RecordingMeteredLlmClient; +import net.openan.a2at.sdk.corpus.engine.loader.ExpectedStep; +import net.openan.a2at.sdk.corpus.engine.loader.InputCase; +import net.openan.a2at.sdk.corpus.engine.loader.InputStep; +import net.openan.a2at.sdk.corpus.engine.registry.ApiRegistry; +import net.openan.a2at.sdk.corpus.engine.util.ThrowableSerializer; + +/** + * Case engine: dispatches the ordered steps of one case to the registered SDK APIs, records the full transcript and + * compares every step against its expectation. Cases are fully isolated: one failing case never aborts the run. + */ +public final class WorkflowEngine { + + private final ApiRegistry registry; + + private final RecordingMeteredLlmClient recorder; + + public WorkflowEngine(ApiRegistry registry, RecordingMeteredLlmClient recorder) { + this.registry = registry; + this.recorder = recorder; + } + + /** Runs one case and returns its transcript; this method never throws for case-level failures. */ + public CaseResult run(String scenarioName, InputCase inputCase) { + recorder.drain(); + + List> priorPayloads = new ArrayList<>(); + List outcomes = new ArrayList<>(); + String failReason = ""; + boolean priorAssertionFailed = false; + boolean crashed = false; + + for (int i = 0; i < inputCase.input().size(); i++) { + InputStep step = inputCase.input().get(i); + ExpectedStep expected = inputCase.expected().get(i); + StepOutcome outcome; + + if (priorAssertionFailed && !"error".equals(expected.result())) { + outcome = StepOutcome.skipped(i + 1, step.api(), softResolve(step, i + 1, priorPayloads), 0L); + } else { + outcome = executeStep(i + 1, step, priorPayloads); + ExpectationComparator.StepAssertion assertion = + ExpectationComparator.compare(expected, outcome); + if (!assertion.passed()) { + if (failReason.isEmpty()) { + failReason = assertion.failMessage(); + } + priorAssertionFailed = true; + } + if (outcome.crashed()) { + crashed = true; + } + } + priorPayloads.add(outcome.payload()); + outcomes.add(outcome); + } + + String verdict = "success"; + if (crashed) { + verdict = "error"; + } else if (priorAssertionFailed) { + verdict = "failure"; + } + + long totalDuration = 0L; + int totalInputToken = 0; + int totalOutputToken = 0; + List interactions = new ArrayList<>(); + int counter = 0; + for (StepOutcome outcome : outcomes) { + counter++; + int stepInputToken = tokenSum(outcome.llmCalls(), true); + int stepOutputToken = tokenSum(outcome.llmCalls(), false); + totalInputToken += stepInputToken; + totalOutputToken += stepOutputToken; + totalDuration += outcome.durationMs(); + interactions.add(new Interaction( + counter, + outcome.api(), + outcome.actualResult(), + outcome.durationMs(), + outcome.request() == null ? Map.of() : outcome.request(), + displayResponse(outcome), + stepInputToken, + stepOutputToken)); + for (RecordingMeteredLlmClient.RecordedLlmCall call : outcome.llmCalls()) { + counter++; + interactions.add(new Interaction( + counter, + "LLM", + call.result(), + call.durationMs(), + call.request(), + call.response(), + call.inputToken(), + call.outputToken())); + } + } + + return new CaseResult( + scenarioName, + inputCase, + verdict, + failReason, + totalDuration, + totalInputToken, + totalOutputToken, + interactions); + } + + private StepOutcome executeStep( + int stepNo, InputStep step, List> priorPayloads) { + long startedAt = System.nanoTime(); + Map request; + try { + request = FromStepResolver.resolve(step.args(), stepNo, priorPayloads); + } catch (RuntimeException error) { + long duration = elapsedMillis(startedAt); + return StepOutcome.error( + stepNo, + step.api(), + step.args(), + ThrowableSerializer.toMap(error), + null, + error.getMessage(), + true, + duration, + recorder.drain()); + } + recorder.drain(); + try { + Map payload = registry.handler(step.api()) + .orElseThrow(() -> new IllegalArgumentException("api not registered: " + step.api())) + .run(request); + List llmCalls = recorder.drain(); + return StepOutcome.success(stepNo, step.api(), request, payload, elapsedMillis(startedAt), llmCalls); + } catch (Throwable error) { + List llmCalls = recorder.drain(); + boolean coded = error instanceof A2ATError; + return StepOutcome.error( + stepNo, + step.api(), + request, + ThrowableSerializer.toMap(error), + coded ? ((A2ATError) error).getCode() : null, + error.getMessage(), + !coded, + elapsedMillis(startedAt), + llmCalls); + } + } + + private static Map softResolve( + InputStep step, int stepNo, List> priorPayloads) { + try { + return FromStepResolver.resolve(step.args(), stepNo, priorPayloads); + } catch (RuntimeException error) { + return step.args(); + } + } + + private static Map displayResponse(StepOutcome outcome) { + if ("error".equals(outcome.actualResult())) { + return outcome.errorResponse() == null ? Map.of() : outcome.errorResponse(); + } + if ("success".equals(outcome.actualResult())) { + return outcome.payload() == null ? Map.of() : outcome.payload(); + } + Map skipped = new LinkedHashMap<>(); + skipped.put("reason", "Previous step assertion failed; this step was skipped"); + return skipped; + } + + private static int tokenSum( + List calls, boolean input) { + int sum = 0; + for (RecordingMeteredLlmClient.RecordedLlmCall call : calls) { + sum += input ? call.inputToken() : call.outputToken(); + } + return sum; + } + + private static long elapsedMillis(long startedAtNanos) { + return Math.max(0L, (System.nanoTime() - startedAtNanos) / 1_000_000L); + } + + /** Execution transcript of one case. */ + public record CaseResult( + String scenario, + InputCase inputCase, + String verdict, + String failReason, + long totalDurationMs, + int totalInputToken, + int totalOutputToken, + List interactions) {} + + /** One transcript row: either an SDK API step or one of its underlying LLM calls, globally numbered. */ + public record Interaction( + int step, + String type, + String result, + long durationMs, + Map request, + Map response, + int inputToken, + int outputToken) {} +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/llm/RecordingMeteredLlmClient.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/llm/RecordingMeteredLlmClient.java new file mode 100644 index 00000000..6ea1b095 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/llm/RecordingMeteredLlmClient.java @@ -0,0 +1,98 @@ +package net.openan.a2at.sdk.corpus.engine.llm; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.corpus.engine.util.ThrowableSerializer; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; + +/** + * Decorator wrapping the real LLM client as the single test seam of the corpus: it records every invocation with the + * complete request (messages, schema, parameters, model), the complete response (content, model, usage), the latency + * and the token counters, then delegates transparently. The production pipeline is otherwise assembled unchanged. + */ +public final class RecordingMeteredLlmClient implements LLMClient { + + /** One captured LLM invocation. */ + public record RecordedLlmCall( + Map request, + Map response, + String result, + long durationMs, + int inputToken, + int outputToken) {} + + private final LLMClient delegate; + + private final String modelName; + + private final List calls = new ArrayList<>(); + + public RecordingMeteredLlmClient(LLMClient delegate, String modelName) { + this.delegate = delegate; + this.modelName = modelName; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + long startedAt = System.nanoTime(); + Map request = new LinkedHashMap<>(); + request.put("messages", new ArrayList<>(messages)); + request.put("jsonSchema", jsonSchema == null ? Map.of() : jsonSchema); + if (temperature != null) { + request.put("temperature", temperature); + } + if (maxTokens != null) { + request.put("maxTokens", maxTokens); + } + request.put("model", modelName); + try { + LLMResponse response = delegate.structured(messages, jsonSchema, temperature, maxTokens); + Map responseMap = new LinkedHashMap<>(); + responseMap.put("content", response.content()); + responseMap.put("model", response.model()); + responseMap.put("usage", response.usage() == null ? Map.of() : response.usage()); + record(new RecordedLlmCall( + request, + responseMap, + "success", + elapsedMillis(startedAt), + tokens(response.usage(), "prompt_tokens"), + tokens(response.usage(), "completion_tokens"))); + return response; + } catch (Throwable error) { + record(new RecordedLlmCall( + request, ThrowableSerializer.toMap(error), "error", elapsedMillis(startedAt), 0, 0)); + throw error; + } + } + + /** Returns the calls recorded since the previous drain and forgets them. */ + public synchronized List drain() { + List snapshot = new ArrayList<>(calls); + calls.clear(); + return snapshot; + } + + private static long elapsedMillis(long startedAtNanos) { + return Math.max(0L, (System.nanoTime() - startedAtNanos) / 1_000_000L); + } + + private static int tokens(Map usage, String key) { + if (usage == null) { + return 0; + } + Integer value = usage.get(key); + return value == null ? 0 : value; + } + + private synchronized void record(RecordedLlmCall call) { + calls.add(call); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CaseFileLoader.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CaseFileLoader.java new file mode 100644 index 00000000..2dfb02fd --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CaseFileLoader.java @@ -0,0 +1,305 @@ +package net.openan.a2at.sdk.corpus.engine.loader; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; + +/** + * Strict loader for one {@code input_case_*.json} file. + * + *

Every structural violation is collected and reported in a single fail-fast message naming the file, the case id + * and the offending construct: unknown keys, missing or blank fields, mismatched input/expected lengths, unknown api + * names, out-of-catalog error codes and malformed {@code $fromStep} references all fail at load time. + */ +public final class CaseFileLoader { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final Set CASE_KEYS = + Set.of("id", "caseDimension", "caseDesc", "caseCategory", "input", "expected"); + + private static final Set STEP_KEYS = Set.of("api", "args"); + + private static final Set EXPECTED_KEYS = Set.of("result", "data", "dataExact", "error"); + + private static final Set ERROR_KEYS = Set.of("errCode", "errMessage", "errMessageContains"); + + private static final Set KNOWN_RESULTS = Set.of("success", "error"); + + private static final Set CATALOG_CODES = Arrays.stream(ErrorCatalog.values()) + .map(ErrorCatalog::getCode) + .collect(Collectors.toUnmodifiableSet()); + + public List load(Path file, Set registeredApis) { + byte[] bytes; + try { + bytes = Files.readAllBytes(file); + } catch (IOException error) { + throw new CorpusLoadException("Failed to read corpus file: " + file + " - " + error.getMessage()); + } + JsonNode root; + try { + root = MAPPER.readTree(new String(bytes, StandardCharsets.UTF_8)); + } catch (IOException error) { + throw new CorpusLoadException(file + " is not valid JSON: " + error.getMessage()); + } + if (root == null || !root.isArray()) { + throw new CorpusLoadException(file + " top level must be a case array"); + } + + List problems = new ArrayList<>(); + List cases = new ArrayList<>(); + Set ids = new LinkedHashSet<>(); + + for (int i = 0; i < root.size(); i++) { + JsonNode node = root.get(i); + String caseLabel = "case[" + i + "]"; + if (!node.isObject()) { + problems.add(caseLabel + " must be an object"); + continue; + } + List unknownKeys = unknownKeys(node, CASE_KEYS); + if (!unknownKeys.isEmpty()) { + problems.add(caseLabel + " unknown keys: " + String.join(", ", unknownKeys)); + continue; + } + String id = requiredText(node, "id", caseLabel, problems); + if (id != null) { + if (ids.contains(id)) { + problems.add(caseLabel + " duplicate id within the scenario: " + id); + } + ids.add(id); + } + requiredText(node, "caseDimension", caseLabel, problems); + requiredText(node, "caseDesc", caseLabel, problems); + requiredText(node, "caseCategory", caseLabel, problems); + + List input = parseInputSteps(node.get("input"), caseLabel, problems, registeredApis); + List expected = + parseExpectedSteps(node.get("expected"), caseLabel, problems, input.size()); + + if (id != null && !problems.isEmpty()) { + appendCaseContext(problems, id, node, caseLabel); + } + cases.add(new InputCase( + safeText(node.get("id")), + safeText(node.get("caseDimension")), + safeText(node.get("caseDesc")), + safeText(node.get("caseCategory")), + input, + expected)); + } + + if (!problems.isEmpty()) { + throw new CorpusLoadException("Corpus file validation failed " + file + ":\n- " + String.join("\n- ", problems)); + } + return cases; + } + + /** Returns the closed set of SDK error catalog codes; used when an expected error code is asserted. */ + public static Set catalogCodes() { + return CATALOG_CODES; + } + + private static List parseInputSteps( + JsonNode inputNode, String caseLabel, List problems, Set registeredApis) { + List steps = new ArrayList<>(); + if (inputNode == null || !inputNode.isArray() || inputNode.isEmpty()) { + problems.add(caseLabel + " input must be a non-empty step array"); + return steps; + } + for (int i = 0; i < inputNode.size(); i++) { + JsonNode stepNode = inputNode.get(i); + String label = caseLabel + ".input[" + i + "]"; + if (!stepNode.isObject()) { + problems.add(label + " must be an object {api, args}"); + continue; + } + List unknownKeys = unknownKeys(stepNode, STEP_KEYS); + if (!unknownKeys.isEmpty()) { + problems.add(label + " unknown keys: " + String.join(", ", unknownKeys)); + continue; + } + String api = requiredText(stepNode, "api", label, problems); + if (api != null && !registeredApis.contains(api)) { + problems.add(label + " api not registered: " + api + " (available: " + String.join(", ", registeredApis) + ")"); + } + Map args = new LinkedHashMap<>(); + if (stepNode.has("args") && stepNode.get("args").isObject()) { + args = MAPPER.convertValue(stepNode.get("args"), new TypeReference>() {}); + validateFromStepRefs(args, label, problems, i + 1); + } else { + problems.add(label + " args must be an object"); + } + steps.add(new InputStep(safeText(stepNode.get("api")), args)); + } + return steps; + } + + private static List parseExpectedSteps( + JsonNode expectedNode, String caseLabel, List problems, int inputSize) { + List expected = new ArrayList<>(); + if (expectedNode == null || !expectedNode.isArray() || expectedNode.isEmpty()) { + problems.add(caseLabel + " expected must be a non-empty array"); + return expected; + } + if (inputSize > 0 && expectedNode.size() != inputSize) { + problems.add(caseLabel + " input/expected length mismatch: " + inputSize + " != " + expectedNode.size()); + } + for (int i = 0; i < expectedNode.size(); i++) { + JsonNode stepNode = expectedNode.get(i); + String label = caseLabel + ".expected[" + i + "]"; + if (!stepNode.isObject()) { + problems.add(label + " must be an object {result, data?, dataExact?, error?}"); + continue; + } + List unknownKeys = unknownKeys(stepNode, EXPECTED_KEYS); + if (!unknownKeys.isEmpty()) { + problems.add(label + " unknown keys: " + String.join(", ", unknownKeys)); + continue; + } + String result = requiredText(stepNode, "result", label, problems); + if (result != null && !KNOWN_RESULTS.contains(result)) { + problems.add(label + " result must be success or error: " + result); + } + Map data = new LinkedHashMap<>(); + if (stepNode.has("data")) { + if (stepNode.get("data").isObject()) { + data = MAPPER.convertValue(stepNode.get("data"), new TypeReference>() {}); + } else { + problems.add(label + " data must be an object"); + } + } + boolean dataExact = false; + if (stepNode.has("dataExact")) { + if (stepNode.get("dataExact").isBoolean()) { + dataExact = stepNode.get("dataExact").asBoolean(); + } else { + problems.add(label + " dataExact must be a boolean"); + } + } + String errCode = ""; + String errMessage = ""; + String errMessageContains = ""; + if (stepNode.has("error")) { + if (!stepNode.get("error").isObject()) { + problems.add(label + " error must be an object {errCode?, errMessage?, errMessageContains?}"); + } else { + JsonNode errorNode = stepNode.get("error"); + List unknownErrorKeys = unknownKeys(errorNode, ERROR_KEYS); + if (!unknownErrorKeys.isEmpty()) { + problems.add(label + ".error unknown keys: " + String.join(", ", unknownErrorKeys)); + } + errCode = optionalText(errorNode, "errCode", label + ".error", problems); + errMessage = optionalText(errorNode, "errMessage", label + ".error", problems); + errMessageContains = + optionalText(errorNode, "errMessageContains", label + ".error", problems); + if (!errCode.isEmpty() && !CATALOG_CODES.contains(errCode)) { + problems.add(label + ".error errCode not in the SDK error catalog: " + errCode); + } + } + } + expected.add(new ExpectedStep(safeResult(result), data, dataExact, errCode, errMessage, errMessageContains)); + } + return expected; + } + + private static void validateFromStepRefs( + Map args, String label, List problems, int stepNumber) { + validateFromStepValue(args, label, problems, stepNumber); + } + + private static void validateFromStepValue(Object value, String label, List problems, int stepNumber) { + if (value instanceof Map map) { + if (map.containsKey("$fromStep") || map.containsKey("$field")) { + Object fromStep = map.get("$fromStep"); + Object field = map.get("$field"); + boolean referenceOnly = + map.size() == 2 && map.containsKey("$fromStep") && map.containsKey("$field"); + if (!referenceOnly) { + problems.add(label + " a $fromStep reference must contain exactly the two keys $fromStep and $field"); + return; + } + if (!(fromStep instanceof Number number) || number.intValue() <= 0) { + problems.add(label + " $fromStep must be a positive integer: " + fromStep); + } else if (number.intValue() >= stepNumber) { + problems.add(label + " $fromStep=" + number.intValue() + " must reference an earlier step (current step " + + stepNumber + ")"); + } + if (field == null || !(field instanceof String text) || text.isBlank()) { + problems.add(label + " $field must be a non-blank string: " + field); + } + return; + } + for (Object entry : map.values()) { + validateFromStepValue(entry, label, problems, stepNumber); + } + } else if (value instanceof List list) { + for (Object entry : list) { + validateFromStepValue(entry, label, problems, stepNumber); + } + } + } + + private static List unknownKeys(JsonNode node, Set allowed) { + List unknown = new ArrayList<>(); + node.fieldNames().forEachRemaining(name -> { + if (!allowed.contains(name)) { + unknown.add(name); + } + }); + return unknown; + } + + private static String requiredText(JsonNode node, String key, String label, List problems) { + JsonNode value = node.get(key); + if (value == null || !value.isTextual() || value.asText().isBlank()) { + problems.add(label + " missing non-blank field: " + key); + return null; + } + return value.asText(); + } + + private static String optionalText(JsonNode node, String key, String label, List problems) { + JsonNode value = node.get(key); + if (value == null || value.isNull()) { + return ""; + } + if (!value.isTextual()) { + problems.add(label + " field " + key + " must be a string"); + return ""; + } + return value.asText(); + } + + private static String safeText(JsonNode node) { + return node == null || !node.isTextual() ? "" : node.asText(); + } + + private static String safeResult(String result) { + return result == null ? "" : result; + } + + /** Appends the case id to every problem raised while parsing this case, for grep-friendly diagnostics. */ + private static void appendCaseContext(List problems, String id, JsonNode node, String caseLabel) { + for (int i = 0; i < problems.size(); i++) { + String problem = problems.get(i); + if (problem.startsWith(caseLabel) && !problem.contains("id=")) { + problems.set(i, problem + " (id=" + id + ")"); + } + } + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CorpusLoadException.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CorpusLoadException.java new file mode 100644 index 00000000..de821a1a --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/CorpusLoadException.java @@ -0,0 +1,9 @@ +package net.openan.a2at.sdk.corpus.engine.loader; + +/** Raised when one corpus case file fails strict load-time validation; the message lists every violation. */ +public final class CorpusLoadException extends RuntimeException { + + public CorpusLoadException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/ExpectedStep.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/ExpectedStep.java new file mode 100644 index 00000000..c97a7374 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/ExpectedStep.java @@ -0,0 +1,12 @@ +package net.openan.a2at.sdk.corpus.engine.loader; + +import java.util.Map; + +/** One per-step expectation, aligned by index with the matching {@link InputStep}. */ +public record ExpectedStep( + String result, + Map data, + boolean dataExact, + String errCode, + String errMessage, + String errMessageContains) {} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputCase.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputCase.java new file mode 100644 index 00000000..28ebcc9f --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputCase.java @@ -0,0 +1,12 @@ +package net.openan.a2at.sdk.corpus.engine.loader; + +import java.util.List; + +/** One workflow case record as declared by {@code input_case_*.json}. */ +public record InputCase( + String id, + String caseDimension, + String caseDesc, + String caseCategory, + List input, + List expected) {} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputStep.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputStep.java new file mode 100644 index 00000000..cbf0a428 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/loader/InputStep.java @@ -0,0 +1,6 @@ +package net.openan.a2at.sdk.corpus.engine.loader; + +import java.util.Map; + +/** One SDK API step; {@code args} carries the request arguments with optional {@code $fromStep} references. */ +public record InputStep(String api, Map args) {} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiHandler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiHandler.java new file mode 100644 index 00000000..2e01e656 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiHandler.java @@ -0,0 +1,15 @@ +package net.openan.a2at.sdk.corpus.engine.registry; + +import java.util.Map; + +/** Invokes one registered SDK facade method with fully resolved (post {@code $fromStep}) arguments. */ +public interface ApiHandler { + + /** + * Runs the SDK call and returns its payload as a serializable map. + * + * @param args resolved step arguments + * @return serializable response payload + */ + Map run(Map args); +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiRegistry.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiRegistry.java new file mode 100644 index 00000000..1d4fff6d --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ApiRegistry.java @@ -0,0 +1,32 @@ +package net.openan.a2at.sdk.corpus.engine.registry; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Explicit registry mapping SDK facade method names to their {@link ApiHandler}. Registration is declarative (never + * reflective): adding a new API is one registry line that the IDE can navigate. Unknown method names fail at load + * time. + */ +public final class ApiRegistry { + + private final Map handlers = new LinkedHashMap<>(); + + public ApiRegistry register(String apiName, ApiHandler handler) { + if (handlers.containsKey(apiName)) { + throw new IllegalStateException("API registered twice: " + apiName); + } + handlers.put(apiName, handler); + return this; + } + + public Optional handler(String apiName) { + return Optional.ofNullable(handlers.get(apiName)); + } + + public java.util.Set apiNames() { + return Collections.unmodifiableSet(handlers.keySet()); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ClientApis.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ClientApis.java new file mode 100644 index 00000000..0a1a8641 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ClientApis.java @@ -0,0 +1,70 @@ +package net.openan.a2at.sdk.corpus.engine.registry; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.TemplateUri; + +/** + * Task-T client-side registration: JSON step arguments are bound to the production + * {@link ClientPromptGenerationOrchestrator} assembled by {@link SdkRuntimeAssembler}. + */ +public final class ClientApis { + + private ClientApis() {} + + public static void registerTaskApis(ApiRegistry registry, ClientPromptGenerationOrchestrator client) { + registry.register("generateTaskPromptFromText", args -> + metadataToMap(client.generateTaskPromptFromText( + Args.text(args, "text"), Args.templateUri(args, "templateUri")))); + registry.register("generateTaskPromptFromDataWithSchema", args -> + metadataToMap(client.generateTaskPromptFromDataWithSchema( + Args.map(args, "data"), Args.map(args, "schema"), Args.templateUri(args, "templateUri")))); + } + + static Map metadataToMap(MetadataContent content) { + Map map = new LinkedHashMap<>(); + map.put("templateUri", content.templateUri()); + map.put("promptText", content.promptText()); + map.put("extensionUri", content.extensionUri()); + return map; + } + + static TemplateUri templateUri(String raw) { + return TemplateUri.parse(raw) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); + } + + static final class Args { + + private Args() {} + + static String text(Map args, String name) { + Object value = args.get(name); + if (!(value instanceof String str)) { + throw new IllegalArgumentException("step argument " + name + " must be a string: " + value); + } + // Blank strings are passed through to the SDK: the facade raises its own coded error + // (e.g. negotiation.invalid_input), which the expectation layer asserts against. + return str; + } + + static Map map(Map args, String name) { + Object value = args.get(name); + if (!(value instanceof Map)) { + throw new IllegalArgumentException("step argument " + name + " must be an object: " + value); + } + @SuppressWarnings("unchecked") + Map typed = (Map) value; + if (typed.isEmpty()) { + throw new IllegalArgumentException("step argument " + name + " must not be an empty object"); + } + return typed; + } + + static TemplateUri templateUri(Map args, String name) { + return ClientApis.templateUri(text(args, name)); + } + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/NegotiationApis.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/NegotiationApis.java new file mode 100644 index 00000000..feac554e --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/NegotiationApis.java @@ -0,0 +1,313 @@ +package net.openan.a2at.sdk.corpus.engine.registry; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; + +public final class NegotiationApis { + + private NegotiationApis() {} + + public static void registerNegotiationApis( + ApiRegistry registry, NegotiationGenerationOrchestrator orchestrator) { + + // ---- from-text generation ---- + + registry.register("generateNegotiationProposePromptFromText", args -> metadataToMap( + orchestrator.generateProposeFromText( + Args.text(args, "text"), + Args.context(args, "context"), + Args.templateUri(args, "templateUri")))); + registry.register("generateNegotiationAcceptPromptFromText", args -> metadataToMap( + orchestrator.generateAcceptFromText( + Args.text(args, "text"), + Args.context(args, "context"), + Args.templateUri(args, "templateUri")))); + registry.register("generateNegotiationRejectPromptFromText", args -> metadataToMap( + orchestrator.generateRejectFromText( + Args.text(args, "text"), + Args.context(args, "context"), + Args.templateUri(args, "templateUri")))); + registry.register("generateNegotiationAbortPromptFromText", args -> metadataToMap( + orchestrator.generateAbortFromText( + Args.text(args, "text"), + Args.context(args, "context"), + Args.templateUri(args, "templateUri")))); + + // ---- from-data generation ---- + + registry.register("generateNegotiationProposePromptFromData", args -> metadataToMap( + orchestrator.generateProposeFromData( + Args.proposeData(args, "data"), + Args.templateUri(args, "templateUri")))); + registry.register("generateNegotiationAcceptPromptFromData", args -> metadataToMap( + orchestrator.generateAcceptFromData( + Args.endingData(args, "data", NegotiationPerformative.ACCEPT), + Args.templateUri(args, "templateUri")))); + registry.register("generateNegotiationRejectPromptFromData", args -> metadataToMap( + orchestrator.generateRejectFromData( + Args.endingData(args, "data", NegotiationPerformative.REJECT), + Args.templateUri(args, "templateUri")))); + registry.register("generateNegotiationAbortPromptFromData", args -> metadataToMap( + orchestrator.generateAbortFromData( + Args.abortData(args, "data"), + Args.templateUri(args, "templateUri")))); + + // ---- validation ---- + + registry.register("validateProposePromptAndDataFilling", args -> filledToMap( + orchestrator.validateProposePromptAndDataFilling( + Args.text(args, "promptText"), + Args.optionalContext(args, "context"), + Args.map(args, "schema"), + Args.templateUri(args, "templateUri")))); + registry.register("validateAcceptPromptAndDataFilling", args -> filledToMap( + orchestrator.validateAcceptPromptAndDataFilling( + Args.text(args, "promptText"), + Args.optionalContext(args, "context"), + Args.map(args, "schema"), + Args.templateUri(args, "templateUri")))); + registry.register("validateRejectPromptAndDataFilling", args -> filledToMap( + orchestrator.validateRejectPromptAndDataFilling( + Args.text(args, "promptText"), + Args.optionalContext(args, "context"), + Args.map(args, "schema"), + Args.templateUri(args, "templateUri")))); + registry.register("validateAbortPromptAndDataFilling", args -> filledToMap( + orchestrator.validateAbortPromptAndDataFilling( + Args.text(args, "promptText"), + Args.optionalContext(args, "context"), + Args.map(args, "schema"), + Args.templateUri(args, "templateUri")))); + } + + static Map metadataToMap(MetadataContent content) { + Map map = new LinkedHashMap<>(); + map.put("templateUri", content.templateUri()); + map.put("promptText", content.promptText()); + map.put("extensionUri", content.extensionUri()); + return map; + } + + static Map filledToMap(FilledParamData filled) { + Map map = new LinkedHashMap<>(); + map.put("data", filled.data() == null ? Map.of() : filled.data()); + return map; + } + + // -- helper types -- + + private static final class Args { + + private Args() {} + + static String text(Map args, String name) { + Object value = args.get(name); + if (!(value instanceof String str)) { + throw new IllegalArgumentException("step argument " + name + " must be a string: " + value); + } + return str; + } + + @SuppressWarnings("unchecked") + static Map map(Map args, String name) { + Object value = args.get(name); + if (!(value instanceof Map)) { + throw new IllegalArgumentException("step argument " + name + " must be an object: " + value); + } + Map typed = (Map) value; + if (typed.isEmpty()) { + throw new IllegalArgumentException("step argument " + name + " must not be an empty object"); + } + return typed; + } + + static TemplateUri templateUri(Map args, String name) { + return ClientApis.templateUri(text(args, name)); + } + + static NegotiationContext context(Map args, String name) { + return contextFromMap(map(args, name)); + } + + static NegotiationContext optionalContext(Map args, String name) { + Object raw = args.get(name); + if (raw == null) { + return null; + } + if (!(raw instanceof Map)) { + throw new IllegalArgumentException("step argument " + name + " must be an object: " + raw); + } + @SuppressWarnings("unchecked") + Map typed = (Map) raw; + return contextFromMap(typed); + } + + static NegotiationProposeData proposeData(Map args, String name) { + Map raw = map(args, name); + Map content = map(raw, "content"); + NegotiationType type = negotiationTypeOf(args); + return new NegotiationProposeData( + contextFromMap(map(raw, "context")), + (NegotiationProposeContent) buildContent(type, NegotiationPerformative.PROPOSE, content)); + } + + static NegotiationEndingData endingData( + Map args, String name, NegotiationPerformative performative) { + Map raw = map(args, name); + Map content = map(raw, "content"); + NegotiationType type = negotiationTypeOf(args); + return new NegotiationEndingData( + contextFromMap(map(raw, "context")), + (NegotiationEndingContent) buildContent(type, performative, content)); + } + + static NegotiationAbortData abortData(Map args, String name) { + Map raw = map(args, name); + return new NegotiationAbortData( + contextFromMap(map(raw, "context")), + new NegotiationAbortContent(text(map(raw, "content"), "termination_reason"))); + } + } + + // -- content factory -- + + private static final Map TYPE_MAP = Map.of( + "information-negotiation", NegotiationType.INFORMATION, + "target-negotiation", NegotiationType.TARGET, + "feasibility-negotiation", NegotiationType.FEASIBILITY); + + private static NegotiationType negotiationTypeOf(Map args) { + String raw = Args.text(args, "templateUri"); + TemplateUri parsed = TemplateUri.parse(raw) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + raw)); + String segment = parsed.pathSegments().get(0); + NegotiationType type = TYPE_MAP.get(segment); + if (type == null) { + throw new IllegalArgumentException("Unrecognized negotiation type segment: " + segment); + } + return type; + } + + private static Object buildContent( + NegotiationType type, NegotiationPerformative performative, Map raw) { + if (performative == NegotiationPerformative.ABORT) { + return new NegotiationAbortContent(required(raw, "termination_reason", "abort content")); + } + if (performative == NegotiationPerformative.ACCEPT || performative == NegotiationPerformative.REJECT) { + return buildEndingContent(type, parseConclusion(raw), raw); + } + return buildProposeContent(type, raw); + } + + private static NegotiationConclusion parseConclusion(Map raw) { + Object rawValue = raw.get("conclusion"); + if (rawValue == null) { + throw new IllegalArgumentException("ending content must declare its conclusion"); + } + return NegotiationConclusion.valueOf(String.valueOf(rawValue)); + } + + private static NegotiationEndingContent buildEndingContent( + NegotiationType type, NegotiationConclusion conclusion, Map raw) { + return switch (type) { + case INFORMATION -> + new InformationEndingContent(conclusion, items(raw.get("items"))); + case TARGET -> + new TargetEndingContent(conclusion, optionalStr(raw, "confirmed_intent"), + optionalStr(raw, "failure_reason")); + case FEASIBILITY -> + new FeasibilityEndingContent(conclusion, required(raw, "feasibility_summary", "ending content")); + }; + } + + private static NegotiationProposeContent buildProposeContent(NegotiationType type, Map raw) { + return switch (type) { + case INFORMATION -> + new InformationProposeContent(items(raw.get("items")), optionalStr(raw, "relationship")); + case TARGET -> + new TargetProposeContent( + required(raw, "target_negotiation_description", "propose content"), + items(raw.get("intent_understanding")), + items(raw.get("alignment_and_clarification")), + items(raw.get("request_for_clarification")), + optionalStr(raw, "target_confirm_request")); + case FEASIBILITY -> { + Object rawAction = raw.get("action"); + if (rawAction == null) { + throw new IllegalArgumentException("Feasibility propose content must declare its action"); + } + yield new FeasibilityProposeContent( + required(raw, "feasibility_negotiation_description", "propose content"), + NegotiationAction.valueOf(String.valueOf(rawAction)), + items(raw.get("contents_to_evaluate")), + items(raw.get("infeasibility_details_and_proposal")), + optionalStr(raw, "feasibility_confirm_request")); + } + }; + } + + private static NegotiationContext contextFromMap(Map raw) { + return new NegotiationContext( + (String) raw.get("id"), + ((Number) raw.get("round")).intValue(), + raw.containsKey("maxRounds") + ? ((Number) raw.get("maxRounds")).intValue() + : NegotiationContext.DEFAULT_MAX_ROUNDS, + NegotiationPerformative.valueOf((String) raw.get("performative"))); + } + + private static List items(Object raw) { + if (raw == null) { + return null; + } + if (!(raw instanceof List rawList)) { + throw new IllegalArgumentException("negotiation items must be an array: " + raw); + } + List items = new ArrayList<>(); + for (Object element : rawList) { + @SuppressWarnings("unchecked") + Map itemMap = (Map) element; + items.add(new NegotiationItem( + required(itemMap, "name", "negotiation item"), + optionalStr(itemMap, "value"))); + } + return items; + } + + private static String required(Map raw, String key, String where) { + Object value = raw.get(key); + if (!(value instanceof String str) || str.isBlank()) { + throw new IllegalArgumentException(where + " requires a non-blank '" + key + "'"); + } + return str; + } + + private static String optionalStr(Map raw, String key) { + Object value = raw.get(key); + return value == null ? null : String.valueOf(value); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/SdkRuntimeAssembler.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/SdkRuntimeAssembler.java new file mode 100644 index 00000000..a1d80338 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/SdkRuntimeAssembler.java @@ -0,0 +1,127 @@ +package net.openan.a2at.sdk.corpus.engine.registry; + +import java.util.List; +import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.LlmConfig; +import net.openan.a2at.sdk.core.model.NegotiationConfig; +import net.openan.a2at.sdk.core.model.PromptComplianceConfig; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.corpus.engine.config.CorpusEnvConfig; +import net.openan.a2at.sdk.corpus.engine.llm.RecordingMeteredLlmClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.providers.OpenAIClient; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; + +/** + * Assembles the SDK runtime the corpus exercises against. + * + *

Every component is produced by the production builders ({@code DefaultA2ATClientBuilder} / + * {@code DefaultA2ATServerBuilder} / {@code NegotiationGenerationOrchestratorBuilder}); the sole test seam is the + * {@link RecordingMeteredLlmClient} injected through the builder {@code llmClient(...)} injection point. The unified + * {@link A2ATConfig} is constructed directly from the corpus test configuration (classpath prompt resources, fixed + * language), so the module never touches production {@code A2AT_LLM_*} environment values. + */ +public final class SdkRuntimeAssembler { + + /** Fully assembled Task-T runtime with the single shared recording LLM client. */ + public record Runtime( + RecordingMeteredLlmClient recorder, + ClientPromptGenerationOrchestrator clientGeneration, + ContentValidator taskValidator) {} + + /** Fully assembled Negotiation-T runtime with the single shared recording LLM client. */ + public record NegotiationRuntime( + RecordingMeteredLlmClient recorder, + NegotiationGenerationOrchestrator orchestrator) {} + + private SdkRuntimeAssembler() {} + + private static RecordingMeteredLlmClient newRecorder(CorpusEnvConfig env) { + LLMClientConfig llmClientConfig = new LLMClientConfig( + env.provider(), + env.model(), + env.apiKey(), + env.baseUrl(), + 10, + env.maxTokens(), + env.temperature(), + env.timeoutSeconds(), + 300, + 100, + false, + null); + return new RecordingMeteredLlmClient(new OpenAIClient(llmClientConfig), env.model()); + } + + public static Runtime taskRuntime() { + CorpusEnvConfig env = CorpusEnvConfig.load(); + RecordingMeteredLlmClient recorder = newRecorder(env); + + LlmConfig llmConfig = new LlmConfig( + env.provider(), + env.model(), + env.apiKey(), + env.baseUrl(), + 10, + env.maxTokens(), + env.temperature(), + env.timeoutSeconds(), + 300, + 100, + false, + null, + env.maxAttempts(), + List.of()); + A2ATConfig config = new A2ATConfig( + new PromptRuntimeConfig("zh-CN", PromptRuntimeConfig.SOURCE_TYPE_CLASSPATH, null), + llmConfig, + new InputLimitConfig(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), + new NegotiationConfig("in_memory"), + new PromptComplianceConfig(false)); + + ClientPromptGenerationOrchestrator clientGeneration = DefaultA2ATClientBuilder.builder() + .config(config) + .envPath(env.envPath()) + .llmClient(recorder) + .buildPromptGenerationOrchestrator(); + ContentValidator taskValidator = DefaultA2ATServerBuilder.builder() + .config(config) + .envPath(env.envPath()) + .llmClient(recorder) + .buildTaskContentValidator(); + + return new Runtime(recorder, clientGeneration, taskValidator); + } + + public static NegotiationRuntime negotiationRuntime() { + CorpusEnvConfig env = CorpusEnvConfig.load(); + RecordingMeteredLlmClient recorder = newRecorder(env); + + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(recorder) + .maxAttempts(env.maxAttempts()) + .build(); + + return new NegotiationRuntime(recorder, orchestrator); + } + + public static ApiRegistry buildTaskRegistry(Runtime runtime) { + ApiRegistry registry = new ApiRegistry(); + ClientApis.registerTaskApis(registry, runtime.clientGeneration()); + ServerApis.registerTaskApis(registry, runtime.taskValidator()); + return registry; + } + + public static ApiRegistry buildNegotiationRegistry(NegotiationRuntime runtime) { + ApiRegistry registry = new ApiRegistry(); + NegotiationApis.registerNegotiationApis(registry, runtime.orchestrator()); + return registry; + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ServerApis.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ServerApis.java new file mode 100644 index 00000000..4a75d3a9 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/registry/ServerApis.java @@ -0,0 +1,28 @@ +package net.openan.a2at.sdk.corpus.engine.registry; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.validation.ContentValidator; + +/** + * Task-T server-side registration: the {@code validateTaskPromptAndDataFilling} step is bound to the production + * {@link ContentValidator} assembled by {@link SdkRuntimeAssembler} from the default server builder. + */ +public final class ServerApis { + + private ServerApis() {} + + public static void registerTaskApis(ApiRegistry registry, ContentValidator taskContentValidator) { + registry.register("validateTaskPromptAndDataFilling", args -> filledToMap(taskContentValidator.validate( + ClientApis.Args.text(args, "promptText"), + ClientApis.Args.map(args, "schema"), + ClientApis.Args.templateUri(args, "templateUri")))); + } + + static Map filledToMap(FilledParamData filled) { + Map map = new LinkedHashMap<>(); + map.put("data", filled.data() == null ? Map.of() : filled.data()); + return map; + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/ResultAggregator.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/ResultAggregator.java new file mode 100644 index 00000000..82da8e54 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/ResultAggregator.java @@ -0,0 +1,269 @@ +package net.openan.a2at.sdk.corpus.engine.report; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.corpus.engine.engine.WorkflowEngine; + +/** + * Aggregates one run into accuracy / latency / token statistics and writes {@code summary_report_.json} plus a + * console table. Accuracy rows group by overall, scenario, case dimension, case category and (for step-level health) + * API method; the 95% target line is marked per row. + */ +public final class ResultAggregator { + + /** 95%+ accuracy target required by the accuracy verification directive. */ + public static final double ACCURACY_TARGET = 0.95; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ResultAggregator() {} + + /** Computes and persists the run summary; returns the written file. */ + public static Path writeSummary(String flowType, List results) { + Map summary = summarize(results); + Path dir = summaryDir(); + try { + Files.createDirectories(dir); + } catch (IOException error) { + throw new IllegalStateException("Failed to create the summary directory: " + dir, error); + } + Path file = dir.resolve("summary_report_" + flowType + ".json"); + try { + MAPPER.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), summary); + } catch (IOException error) { + throw new IllegalStateException("Failed to write the summary: " + file, error); + } + printTable(flowType, summary); + return file; + } + + /** + * Prints one case result as it completes so every executed case - including failures - is visible in the console + * log of the run. Failed or crashed cases additionally print their full interaction trace. + */ + public static void printCaseLine(WorkflowEngine.CaseResult result) { + String fail = result.failReason().isEmpty() + ? "" + : " | fail: " + result.failReason(); + System.out.printf( + "[case] %s/%s => %s | %dms | tokens %d%s%n", + result.scenario(), + result.inputCase().id(), + result.verdict(), + result.totalDurationMs(), + result.totalInputToken() + result.totalOutputToken(), + fail); + if ("success".equals(result.verdict())) { + return; + } + for (WorkflowEngine.Interaction interaction : result.interactions()) { + String detail = ""; + Map response = interaction.response(); + if (response != null) { + Object code = response.get("errorCode"); + Object message = response.get("message"); + if (code != null || message != null) { + detail = " | " + (code == null ? "" : code) + " " + abbreviate( + String.valueOf(message), 160); + } + } + System.out.printf( + " s%-4d %-30s %-8s %6dms in=%d out=%d%s%n", + interaction.step(), + interaction.type(), + interaction.result(), + interaction.durationMs(), + interaction.inputToken(), + interaction.outputToken(), + detail); + } + } + + static Map summarize(List results) { + Map summary = new LinkedHashMap<>(); + summary.put("caseTotal", results.size()); + summary.put("accuracyTarget", ACCURACY_TARGET); + summary.put("rows", caseRows(results)); + summary.put("apiStepRows", apiStepRows(results)); + summary.put("durationMs", percentileStats(stepDurations(results))); + summary.put("tokens", tokenStats(results)); + return summary; + } + + private static List> caseRows(List results) { + Map buckets = new LinkedHashMap<>(); + for (WorkflowEngine.CaseResult result : results) { + bucket(buckets, "overall", "overall").count(result); + bucket(buckets, "scenario", result.scenario()).count(result); + bucket(buckets, "dimension", result.inputCase().caseDimension()).count(result); + bucket(buckets, "category", result.inputCase().caseCategory()).count(result); + } + return bucketRows(buckets); + } + + private static List> apiStepRows(List results) { + Map buckets = new LinkedHashMap<>(); + for (WorkflowEngine.CaseResult result : results) { + for (WorkflowEngine.Interaction interaction : result.interactions()) { + if ("LLM".equals(interaction.type())) { + continue; + } + if ("skipped".equals(interaction.result())) { + continue; + } + bucket(buckets, "api", interaction.type()).count("success".equals(interaction.result())); + } + } + return bucketRows(buckets); + } + + private static final class Bucket { + + final String scope; + + final String key; + + long total; + + long success; + + Bucket(String scope, String key) { + this.scope = scope; + this.key = key; + } + + void count(WorkflowEngine.CaseResult result) { + total++; + if ("success".equals(result.verdict())) { + success++; + } + } + + void count(boolean passed) { + total++; + if (passed) { + success++; + } + } + } + + private static Bucket bucket(Map buckets, String scope, String key) { + return buckets.computeIfAbsent(scope + "/" + key, ignored -> new Bucket(scope, key)); + } + + private static List> bucketRows(Map buckets) { + List> rows = new ArrayList<>(); + buckets.values().stream() + .sorted(Comparator.comparing((Bucket b) -> b.scope).thenComparing(b -> b.key)) + .forEach(bucket -> { + Map row = new LinkedHashMap<>(); + row.put("scope", bucket.scope); + row.put("key", bucket.key); + row.put("total", bucket.total); + row.put("success", bucket.success); + row.put("failure", bucket.total - bucket.success); + row.put("accuracy", percentage(bucket.success, bucket.total).doubleValue()); + row.put("meetsTarget", bucket.total > 0 && percentage(bucket.success, bucket.total) + .doubleValue() >= ACCURACY_TARGET); + rows.add(row); + }); + return rows; + } + + private static List stepDurations(List results) { + List durations = new ArrayList<>(); + for (WorkflowEngine.CaseResult result : results) { + for (WorkflowEngine.Interaction interaction : result.interactions()) { + if (!"LLM".equals(interaction.type())) { + durations.add(interaction.durationMs()); + } + } + } + return durations; + } + + private static Map percentileStats(List durations) { + Map stats = new LinkedHashMap<>(); + stats.put("count", durations.size()); + if (!durations.isEmpty()) { + List sorted = durations.stream().sorted().toList(); + stats.put("p50", sorted.get(Math.min(sorted.size() - 1, sorted.size() * 50 / 100))); + stats.put("p95", sorted.get(Math.min(sorted.size() - 1, sorted.size() * 95 / 100))); + stats.put("max", sorted.get(sorted.size() - 1)); + } + return stats; + } + + private static Map tokenStats(List results) { + long input = 0; + long output = 0; + for (WorkflowEngine.CaseResult result : results) { + input += result.totalInputToken(); + output += result.totalOutputToken(); + } + Map stats = new LinkedHashMap<>(); + stats.put("totalInputToken", input); + stats.put("totalOutputToken", output); + stats.put("totalToken", input + output); + stats.put("meanInputTokenPerCase", results.isEmpty() ? 0 : Math.round((double) input / results.size())); + stats.put("meanOutputTokenPerCase", results.isEmpty() ? 0 : Math.round((double) output / results.size())); + return stats; + } + + private static BigDecimal percentage(long success, long total) { + if (total == 0) { + return BigDecimal.ZERO; + } + return BigDecimal.valueOf(success) + .multiply(BigDecimal.valueOf(100)) + .divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP); + } + + private static Path summaryDir() { + String override = System.getProperty("corpus.output.dir"); + if (override != null && !override.isBlank()) { + return Path.of(override).toAbsolutePath().normalize(); + } + return Path.of(System.getProperty("user.dir", "."), "target", "corpus").toAbsolutePath().normalize(); + } + + @SuppressWarnings("unchecked") + private static void printTable(String flowType, Map summary) { + System.out.println(); + System.out.println("===== a2a-t-corpus run summary (" + flowType + ", target " + + ACCURACY_TARGET * 100 + "%)====="); + List> rows = (List>) summary.get("rows"); + System.out.printf("%-12s %-30s %6s %8s %9s %8s%n", "scope", "key", "total", "success", "accuracy", "meets"); + if (rows != null) { + for (Map row : rows) { + System.out.printf("%-12s %-30s %6d %8d %9.2f%% %8s%n", + row.get("scope"), + abbreviate(String.valueOf(row.get("key")), 30), + ((Number) row.get("total")).longValue(), + ((Number) row.get("success")).longValue(), + ((Number) row.get("accuracy")).doubleValue(), + Boolean.TRUE.equals(row.get("meetsTarget")) ? "YES" : "NO"); + } + } + Map tokens = (Map) summary.get("tokens"); + if (tokens != null) { + System.out.printf("Token totals: input=%d output=%d total=%d%n", + ((Number) tokens.get("totalInputToken")).longValue(), + ((Number) tokens.get("totalOutputToken")).longValue(), + ((Number) tokens.get("totalToken")).longValue()); + } + } + + private static String abbreviate(String value, int maxLength) { + return value.length() <= maxLength ? value : value.substring(0, maxLength - 1) + "…"; + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/TranscriptWriter.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/TranscriptWriter.java new file mode 100644 index 00000000..ac685ea0 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/report/TranscriptWriter.java @@ -0,0 +1,88 @@ +package net.openan.a2at.sdk.corpus.engine.report; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.corpus.engine.engine.WorkflowEngine; + +/** + * Writes the execution transcript of one scenario to {@code output_result_.json}. + * + *

Each run fully overwrites the file: it reflects exactly the cases executed in that run (a filtered run therefore + * contains only the matching cases). The default destination is the scenario directory itself (input and output live + * side by side for review and the CSV tooling); {@code -Dcorpus.output.dir=

} redirects every scenario output + * into one shared directory while keeping the per-scenario layout. + */ +public final class TranscriptWriter { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private TranscriptWriter() {} + + public static Path writeScenarioOutput( + String flowType, String scenarioName, Path defaultScenarioDir, List results) { + Path outputDir = outputDir(scenarioName, defaultScenarioDir); + try { + Files.createDirectories(outputDir); + } catch (IOException error) { + throw new IllegalStateException("Failed to create the output directory: " + outputDir, error); + } + Path file = outputDir.resolve("output_result_" + flowType + ".json"); + List> records = new ArrayList<>(); + for (WorkflowEngine.CaseResult result : results) { + records.add(toRecord(result)); + } + try { + MAPPER.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), records); + } catch (IOException error) { + throw new IllegalStateException("Failed to write the run results: " + file, error); + } + return file; + } + + private static Map toRecord(WorkflowEngine.CaseResult result) { + Map record = new LinkedHashMap<>(); + record.put("id", result.inputCase().id()); + record.put("caseDimension", result.inputCase().caseDimension()); + record.put("caseDesc", result.inputCase().caseDesc()); + record.put("caseCategory", result.inputCase().caseCategory()); + record.put("totalTime", result.totalDurationMs()); + record.put("totalInputToken", result.totalInputToken()); + record.put("totalOutputToken", result.totalOutputToken()); + record.put("totalToken", result.totalInputToken() + result.totalOutputToken()); + record.put("result", result.verdict()); + if (!result.failReason().isEmpty()) { + record.put("failReason", result.failReason()); + } + Map output = new LinkedHashMap<>(); + List> interactions = new ArrayList<>(); + for (WorkflowEngine.Interaction interaction : result.interactions()) { + Map serialized = new LinkedHashMap<>(); + serialized.put("step", String.valueOf(interaction.step())); + serialized.put("type", interaction.type()); + serialized.put("result", interaction.result()); + serialized.put("duration_ms", interaction.durationMs()); + serialized.put("request", interaction.request()); + serialized.put("response", interaction.response()); + serialized.put("inputToken", interaction.inputToken()); + serialized.put("outputToken", interaction.outputToken()); + interactions.add(serialized); + } + output.put("interactions", interactions); + record.put("output", output); + return record; + } + + static Path outputDir(String scenarioName, Path defaultScenarioDir) { + String override = System.getProperty("corpus.output.dir"); + if (override != null && !override.isBlank()) { + return Path.of(override).toAbsolutePath().normalize().resolve(scenarioName); + } + return defaultScenarioDir; + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/util/ThrowableSerializer.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/util/ThrowableSerializer.java new file mode 100644 index 00000000..a351ccf9 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/engine/util/ThrowableSerializer.java @@ -0,0 +1,84 @@ +package net.openan.a2at.sdk.corpus.engine.util; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATError; + +/** + * Serializes a thrown error into a structured map carrying every inspectable property, per the transcript contract: + * exception class, machine-readable error code for {@link A2ATError} instances, message, all public getter values and + * the cause chain. + */ +public final class ThrowableSerializer { + + private static final int MAX_CAUSE_DEPTH = 3; + + private ThrowableSerializer() {} + + public static Map toMap(Throwable error) { + Map serialized = new LinkedHashMap<>(); + fill(serialized, error, 0); + return serialized; + } + + private static void fill(Map target, Throwable error, int depth) { + target.put("exception", error.getClass().getName()); + target.put("message", error.getMessage()); + if (error instanceof A2ATError a2atError) { + target.put("errorCode", a2atError.getCode()); + } + target.put("properties", propertiesOf(error)); + Throwable cause = error.getCause(); + if (cause != null && depth < MAX_CAUSE_DEPTH) { + Map causeMap = new LinkedHashMap<>(); + fill(causeMap, cause, depth + 1); + target.put("cause", causeMap); + } + } + + private static Map propertiesOf(Throwable error) { + Map properties = new LinkedHashMap<>(); + for (Method method : error.getClass().getMethods()) { + if (method.getParameterCount() != 0) { + continue; + } + String name = method.getName(); + if (!name.startsWith("get") && !name.startsWith("is")) { + continue; + } + if (EXCLUDED.contains(name)) { + continue; + } + Object value; + try { + value = method.invoke(error); + } catch (IllegalAccessException | InvocationTargetException ignored) { + continue; + } + if (value == null || value instanceof Throwable) { + continue; + } + properties.put(propertyName(name), value); + } + return properties; + } + + private static String propertyName(String getterName) { + String base = getterName.startsWith("is") ? getterName.substring(2) : getterName.substring(3); + if (base.isEmpty()) { + return getterName; + } + return Character.toLowerCase(base.charAt(0)) + base.substring(1); + } + + private static final java.util.Set EXCLUDED = java.util.Set.of( + "getClass", + "getMessage", + "getLocalizedMessage", + "getCause", + "getStackTrace", + "getSuppressed", + "getCode"); +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationSelfGuardTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationSelfGuardTest.java new file mode 100644 index 00000000..f00a754f --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationSelfGuardTest.java @@ -0,0 +1,58 @@ +package net.openan.a2at.sdk.corpus.suites.negotiation; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import net.openan.a2at.sdk.corpus.engine.CorpusWorkFlowSuite; +import net.openan.a2at.sdk.corpus.engine.discover.ScenarioScanner; +import net.openan.a2at.sdk.corpus.engine.loader.CaseFileLoader; +import net.openan.a2at.sdk.corpus.engine.loader.ExpectedStep; +import net.openan.a2at.sdk.corpus.engine.loader.InputCase; +import org.junit.jupiter.api.Test; + +/** + * Negotiation-T corpus self-guard: validates every Negotiation-T scenario case file structurally without any LLM call. + * Unknown keys, misaligned expectations, unknown api names, out-of-catalog error codes and category/expectation + * contradictions all fail here. + */ +public final class NegotiationSelfGuardTest { + + private static final String CATEGORY_NORMAL = "正常"; + private static final String CATEGORY_EXCEPTION = "异常"; + + @Test + void negotiationScenarioCaseFilesAreStructurallyValid() { + List problems = new ArrayList<>(); + for (String flowFile : List.of( + CorpusWorkFlowSuite.INPUT_CASE_FROM_TEXT, CorpusWorkFlowSuite.INPUT_CASE_FROM_DATA)) { + for (ScenarioScanner.Scenario scenario : ScenarioScanner.discover("negotiation", flowFile)) { + List cases = new CaseFileLoader() + .load(scenario.inputFile(flowFile), CorpusWorkFlowSuite.negotiationApiNames()); + for (InputCase inputCase : cases) { + checkCategoryContract(inputCase, problems); + } + } + } + assertTrue(problems.isEmpty(), "Negotiation self-guard failures:\n- " + String.join("\n- ", problems)); + } + + private static void checkCategoryContract(InputCase inputCase, List problems) { + long errorExpected = 0; + for (ExpectedStep expectedStep : inputCase.expected()) { + if ("error".equals(expectedStep.result())) { + errorExpected++; + if (expectedStep.errCode().isBlank()) { + problems.add(inputCase.id() + ": error expectations must declare an errCode from the SDK error catalog"); + } + } + } + if (CATEGORY_EXCEPTION.equals(inputCase.caseCategory()) && errorExpected == 0) { + problems.add(inputCase.id() + ": categorized as " + CATEGORY_EXCEPTION + " but no step expects an error"); + } + if (CATEGORY_NORMAL.equals(inputCase.caseCategory()) && errorExpected > 0) { + problems.add(inputCase.id() + ": categorized as " + CATEGORY_NORMAL + " but a step expects an error; use " + + CATEGORY_EXCEPTION + " or \"boundary\""); + } + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromDataWorkFlowTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromDataWorkFlowTest.java new file mode 100644 index 00000000..91a6de6f --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromDataWorkFlowTest.java @@ -0,0 +1,44 @@ +package net.openan.a2at.sdk.corpus.suites.negotiation; + +import java.util.Set; +import net.openan.a2at.sdk.corpus.engine.CorpusWorkFlowSuite; + +/** + * Negotiation-T structured-input workflow suite: for every scenario directory under + * {@code suites/negotiation/resources/}, runs the recorded SDK API flow of each case in + * {@code input_case_from_data.json} against a real LLM (the from-data generation leg is + * deterministic; the validation leg uses the real semantic pipeline). + * + *

Default API flow (also shown in the case files): one {@code generateNegotiation*PromptFromData} step followed + * by its matching {@code validate*PromptAndDataFilling} step. + * + *

Filters: {@code -Dcorpus.scenario} and {@code -Dcase.filter}, see + * {@link NegotiationTFromTextWorkFlowTest}. + */ +public final class NegotiationTFromDataWorkFlowTest extends CorpusWorkFlowSuite { + + @Override + protected String extensionFolder() { + return "negotiation"; + } + + @Override + protected String inputFileName() { + return INPUT_CASE_FROM_DATA; + } + + @Override + protected String flowType() { + return FLOW_FROM_DATA; + } + + @Override + protected String runtimeKind() { + return "negotiation"; + } + + @Override + protected Set apiNames() { + return negotiationApiNames(); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromTextWorkFlowTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromTextWorkFlowTest.java new file mode 100644 index 00000000..76e04fd6 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/NegotiationTFromTextWorkFlowTest.java @@ -0,0 +1,44 @@ +package net.openan.a2at.sdk.corpus.suites.negotiation; + +import java.util.Set; +import net.openan.a2at.sdk.corpus.engine.CorpusWorkFlowSuite; + +/** + * Negotiation-T natural-language workflow suite: for every scenario directory under + * {@code suites/negotiation/resources/}, runs the recorded SDK API flow of each case in + * {@code input_case_from_text.json} against a real LLM. + * + *

Default API flow (also shown in the case files): one {@code generateNegotiation*PromptFromText} step followed + * by its matching {@code validate*PromptAndDataFilling} step; the concrete performative (propose / accept / reject / + * abort) is chosen per case. + * + *

Filters: {@code -Dcorpus.scenario=ran-energy-saving-target-negotiation} (glob, comma-separated) and + * {@code -Dcase.filter=TC00000001} (glob on case id). + */ +public final class NegotiationTFromTextWorkFlowTest extends CorpusWorkFlowSuite { + + @Override + protected String extensionFolder() { + return "negotiation"; + } + + @Override + protected String inputFileName() { + return INPUT_CASE_FROM_TEXT; + } + + @Override + protected String flowType() { + return FLOW_FROM_TEXT; + } + + @Override + protected String runtimeKind() { + return "negotiation"; + } + + @Override + protected Set apiNames() { + return negotiationApiNames(); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_data.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_data.json new file mode 100644 index 00000000..4746d0cc --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_data.json @@ -0,0 +1,78 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-结构化数据完整", + "caseDesc": "无线节能-可行性协商-propose-结构数据生成并校验", + "caseCategory": "正常", + "input": [ + { + "api": "generateNegotiationProposePromptFromData", + "args": { + "data": { + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "content": { + "feasibility_negotiation_description": "已收到无线网络节能任务请求,请协助评估该节能目标能否达成,目标内容:区域松山湖管委会,分时段速率保障目标{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps},节能时段08:00~12:00,小区制式NR。请评估。", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": [ + { + "name": "目标达成", + "value": "松山湖管委会区域的NR小区在08:00~12:00时段执行节能,需保障分时段速率目标,评估目标是否可达成" + }, + { + "name": "资源现状", + "value": "小区当前配置:NR制式,节能时段内可关闭的载波频点1650/1600" + } + ], + "infeasibility_details_and_proposal": null, + "feasibility_confirm_request": null + } + }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": { + "$fromStep": 1, + "$field": "promptText" + }, + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_text.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_text.json new file mode 100644 index 00000000..beb8a9ca --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-feasibility-negotiation/input_case_from_text.json @@ -0,0 +1,61 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-意图清晰参数完整", + "caseDesc": "无线节能-可行性协商-propose-自然语言生成并校验", + "caseCategory": "正常", + "input": [ + { + "api": "generateNegotiationProposePromptFromText", + "args": { + "text": "无线网络节能任务已完成目标澄清,区域松山湖管委会,分时段速率保障目标{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps},节能时段08:00~12:00,小区制式NR,请评估该节能目标能否达成。", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": { + "$fromStep": 1, + "$field": "promptText" + }, + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/feasibility-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_data.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_data.json new file mode 100644 index 00000000..af498923 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_data.json @@ -0,0 +1,79 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-结构化数据完整", + "caseDesc": "无线节能-信息协商-propose-结构数据生成并校验", + "caseCategory": "正常", + "input": [ + { + "api": "generateNegotiationProposePromptFromData", + "args": { + "data": { + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "content": { + "items": [ + { + "name": "节能区域信息", + "value": "区域:松山湖管委会" + }, + { + "name": "节能速率保障目标", + "value": "分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}" + }, + { + "name": "可关闭下行频点", + "value": "下行频点号:1650,1600" + } + ], + "relationship": "以上信息项共同用于配置无线网络节能任务,缺失任一项均无法生成完整的节能方案" + } + }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": { + "$fromStep": 1, + "$field": "promptText" + }, + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_text.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_text.json new file mode 100644 index 00000000..2d0b460f --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-information-negotiation/input_case_from_text.json @@ -0,0 +1,117 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-意图清晰参数完整", + "caseDesc": "无线节能-信息协商-propose-自然语言生成并校验", + "caseCategory": "正常", + "input": [ + { + "api": "generateNegotiationProposePromptFromText", + "args": { + "text": "无线网络节能任务缺少必要信息,请补充节能区域信息(如松山湖管委会)、节能速率保障目标(如20Mbps)、以及节能时段内允许关闭的下行频点号(如1650),请提供这些信息项以完成节能任务配置。", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": { + "$fromStep": 1, + "$field": "promptText" + }, + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + }, + { + "id": "TC00000002", + "caseDimension": "02-校验空报文", + "caseDesc": "信息协商-propose 后 validate 传入空报文,返回 negotiation.invalid_input", + "caseCategory": "异常", + "input": [ + { + "api": "generateNegotiationProposePromptFromText", + "args": { + "text": "请补充节能区域信息与速率保障目标。", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": "", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/information-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "error", + "data": {}, + "error": { + "errCode": "negotiation.invalid_input", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_data.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_data.json new file mode 100644 index 00000000..3034bd60 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_data.json @@ -0,0 +1,87 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-结构化数据完整", + "caseDesc": "无线节能-目标协商-propose-结构数据生成并校验", + "caseCategory": "正常", + "input": [ + { + "api": "generateNegotiationProposePromptFromData", + "args": { + "data": { + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "content": { + "target_negotiation_description": "无线网络节能任务:区域松山湖管委会,分时段速率保障目标{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps},小区制式NR,需确认目标是否清晰", + "intent_understanding": [ + { + "name": "区域", + "value": "松山湖管委会" + }, + { + "name": "分时段速率保障目标", + "value": "{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}" + }, + { + "name": "小区制式", + "value": "NR" + } + ], + "alignment_and_clarification": null, + "request_for_clarification": [ + { + "name": "节能时间范围", + "value": "未明确指定默认全时段节能?" + } + ], + "target_confirm_request": null + } + }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": { + "$fromStep": 1, + "$field": "promptText" + }, + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_text.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_text.json new file mode 100644 index 00000000..d742fc2e --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/negotiation/resources/ran-energy-saving-target-negotiation/input_case_from_text.json @@ -0,0 +1,117 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-意图清晰参数完整", + "caseDesc": "无线节能-目标协商-propose-自然语言生成并校验", + "caseCategory": "正常", + "input": [ + { + "api": "generateNegotiationProposePromptFromText", + "args": { + "text": "我们需要确认无线网络节能任务的速率保障目标,区域为松山湖管委会,计划的分时段速率保障目标为{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps},节能的小区制式为NR,请确认这些目标是否清晰。", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": { + "$fromStep": 1, + "$field": "promptText" + }, + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + } + ] + }, + { + "id": "TC00000002", + "caseDimension": "02-校验空报文", + "caseDesc": "目标协商-propose 后 validate 传入空报文,返回 negotiation.invalid_input", + "caseCategory": "异常", + "input": [ + { + "api": "generateNegotiationProposePromptFromText", + "args": { + "text": "无线网络节能任务,区域松山湖管委会,分时段速率保障目标{00:00~06:00,2Mbps}。", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1" + } + }, + { + "api": "validateProposePromptAndDataFilling", + "args": { + "promptText": "", + "context": { + "id": "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round": 1, + "maxRounds": 5, + "performative": "PROPOSE" + }, + "schema": { + "type": "object", + "properties": {} + }, + "templateUri": "Negotiation-T/target-negotiation/propose/v1" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { + "errCode": "", + "errMessage": "" + } + }, + { + "result": "error", + "data": {}, + "error": { + "errCode": "negotiation.invalid_input", + "errMessage": "" + } + } + ] + } +] diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskSelfGuardTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskSelfGuardTest.java new file mode 100644 index 00000000..18c16003 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskSelfGuardTest.java @@ -0,0 +1,63 @@ +package net.openan.a2at.sdk.corpus.suites.task; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import net.openan.a2at.sdk.corpus.engine.CorpusWorkFlowSuite; +import net.openan.a2at.sdk.corpus.engine.discover.ScenarioScanner; +import net.openan.a2at.sdk.corpus.engine.loader.CaseFileLoader; +import net.openan.a2at.sdk.corpus.engine.loader.ExpectedStep; +import net.openan.a2at.sdk.corpus.engine.loader.InputCase; +import org.junit.jupiter.api.Test; + +/** + * Task-T self-guard (meta test): validates every Task-T scenario case file structurally without any LLM call. This is + * the fast gate hit first in IDE runs and local workflows - unknown keys, misaligned expectations, unknown api names, + * out-of-catalog error codes and category/expectation contradictions all fail here. + * + *

Id uniqueness is enforced per-file by the {@link CaseFileLoader}; ids may repeat across different flow files + * within the same scenario. + */ +public final class TaskSelfGuardTest { + + /** Categories carrying a cross-step expectation contract (case data is Chinese). */ + static final String CATEGORY_NORMAL = "正常"; + + static final String CATEGORY_EXCEPTION = "异常"; + + @Test + void taskScenarioCaseFilesAreStructurallyValid() { + List problems = new ArrayList<>(); + for (String flowFile : List.of( + CorpusWorkFlowSuite.INPUT_CASE_FROM_TEXT, CorpusWorkFlowSuite.INPUT_CASE_FROM_DATA)) { + for (ScenarioScanner.Scenario scenario : ScenarioScanner.discover("task", flowFile)) { + List cases = new CaseFileLoader() + .load(scenario.inputFile(flowFile), CorpusWorkFlowSuite.taskApiNames()); + for (InputCase inputCase : cases) { + checkCategoryContract(inputCase, problems); + } + } + } + assertTrue(problems.isEmpty(), "Corpus self-guard failures:\n- " + String.join("\n- ", problems)); + } + + private static void checkCategoryContract(InputCase inputCase, List problems) { + long errorExpected = 0; + for (ExpectedStep expectedStep : inputCase.expected()) { + if ("error".equals(expectedStep.result())) { + errorExpected++; + if (expectedStep.errCode().isBlank()) { + problems.add(inputCase.id() + ": error expectations must declare an errCode from the SDK error catalog"); + } + } + } + if (CATEGORY_EXCEPTION.equals(inputCase.caseCategory()) && errorExpected == 0) { + problems.add(inputCase.id() + ": categorized as " + CATEGORY_EXCEPTION + " but no step expects an error"); + } + if (CATEGORY_NORMAL.equals(inputCase.caseCategory()) && errorExpected > 0) { + problems.add(inputCase.id() + ": categorized as " + CATEGORY_NORMAL + " but a step expects an error; use " + + CATEGORY_EXCEPTION + " or \"boundary\""); + } + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskTFromDataWorkFlowTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskTFromDataWorkFlowTest.java new file mode 100644 index 00000000..5a3e258c --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskTFromDataWorkFlowTest.java @@ -0,0 +1,37 @@ +package net.openan.a2at.sdk.corpus.suites.task; + +import java.util.Set; +import net.openan.a2at.sdk.corpus.engine.CorpusWorkFlowSuite; + +/** + * Task-T structured-input workflow suite: for every scenario directory under + * {@code suites/task/resources/}, runs the recorded SDK API flow of each case in + * {@code input_case_from_data.json} against a real LLM. + * + *

Default API flow (also shown in the case files): {@code generateTaskPromptFromDataWithSchema} → + * {@code validateTaskPromptAndDataFilling}. + * + *

Filters: {@code -Dcorpus.scenario} and {@code -Dcase.filter}, see {@link TaskTFromTextWorkFlowTest}. + */ +public final class TaskTFromDataWorkFlowTest extends CorpusWorkFlowSuite { + + @Override + protected String extensionFolder() { + return "task"; + } + + @Override + protected String inputFileName() { + return INPUT_CASE_FROM_DATA; + } + + @Override + protected String flowType() { + return FLOW_FROM_DATA; + } + + @Override + protected Set apiNames() { + return taskApiNames(); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskTFromTextWorkFlowTest.java b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskTFromTextWorkFlowTest.java new file mode 100644 index 00000000..08eeb20d --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/TaskTFromTextWorkFlowTest.java @@ -0,0 +1,38 @@ +package net.openan.a2at.sdk.corpus.suites.task; + +import java.util.Set; +import net.openan.a2at.sdk.corpus.engine.CorpusWorkFlowSuite; + +/** + * Task-T natural-language workflow suite: for every scenario directory under + * {@code suites/task/resources/}, runs the recorded SDK API flow of each case in + * {@code input_case_from_text.json} against a real LLM. + * + *

Default API flow (also shown in the case files): {@code generateTaskPromptFromText} → + * {@code validateTaskPromptAndDataFilling}. + * + *

Filters: {@code -Dcorpus.scenario=private-line-complaint} (glob, comma-separated) and + * {@code -Dcase.filter=TC00000001} (glob on case id). + */ +public final class TaskTFromTextWorkFlowTest extends CorpusWorkFlowSuite { + + @Override + protected String extensionFolder() { + return "task"; + } + + @Override + protected String inputFileName() { + return INPUT_CASE_FROM_TEXT; + } + + @Override + protected String flowType() { + return FLOW_FROM_TEXT; + } + + @Override + protected Set apiNames() { + return taskApiNames(); + } +} \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/private-line-complaint/input_case_from_data.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/private-line-complaint/input_case_from_data.json new file mode 100644 index 00000000..1f240089 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/private-line-complaint/input_case_from_data.json @@ -0,0 +1,197 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-结构化参数完整", + "caseDesc": "基础档-结构化数据生成投诉任务报文并校验提取", + "caseCategory": "正常", + "input": [ + { + "api": "generateTaskPromptFromDataWithSchema", + "args": { + "data": { + "accessPort": "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "bizScenario": "专线中断", + "eventSerialNo": "fault-id-1-017-20230511-09013" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "accessPort": { + "type": "string", + "description": "接入端口名称,唯一标识被投诉的专线对象" + }, + "bizScenario": { + "type": "string", + "description": "投诉分类场景,仅允许取值:专线中断、专线质差", + "enum": ["专线中断", "专线质差"] + }, + "eventSerialNo": { + "type": "string", + "description": "OSS 侧受理的投诉工单或事件流水号" + } + }, + "required": ["accessPort", "bizScenario"] + }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "accessPort": { + "type": "string", + "description": "接入端口名称,唯一标识被投诉的专线对象" + }, + "bizScenario": { + "type": "string", + "description": "投诉分类场景,仅允许取值:专线中断、专线质差", + "enum": ["专线中断", "专线质差"] + } + }, + "required": ["accessPort", "bizScenario"] + }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "promptText": { + "$fromStep": 1, + "$field": "promptText" + } + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { "errCode": "", "errMessage": "" } + }, + { + "result": "success", + "data": { + "accessPort": "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "bizScenario": "专线中断" + }, + "error": { "errCode": "", "errMessage": "" } + } + ] + }, + { + "id": "TC00000002", + "caseDimension": "02-模板地址错误", + "caseDesc": "结构化数据传入不存在的模板 URI,返回 template.not_found", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromDataWithSchema", + "args": { + "data": { + "accessPort": "P781-珠江新城-PTN7900-23-TPA1EG24-17" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "accessPort": { + "type": "string", + "description": "接入端口名称,唯一标识被投诉的专线对象" + } + }, + "required": ["accessPort"] + }, + "templateUri": "Task-T/network-layer/no-such-template/v1" + } + } + ], + "expected": [ + { + "result": "error", + "data": {}, + "error": { "errCode": "template.not_found", "errMessage": "" } + } + ] + }, + { + "id": "TC00000003", + "caseDimension": "04-最小必填参数", + "caseDesc": "结构化数据只给必填参数,要求仍能生成并校验提取", + "caseCategory": "边界", + "input": [ + { + "api": "generateTaskPromptFromDataWithSchema", + "args": { + "data": { + "accessPort": "P850-琶洲-PTN7900-12-TPA1EG08-03", + "bizScenario": "专线质差" + }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "accessPort": { + "type": "string", + "description": "接入端口名称,唯一标识被投诉的专线对象" + }, + "bizScenario": { + "type": "string", + "description": "投诉分类场景,仅允许取值:专线中断、专线质差", + "enum": ["专线中断", "专线质差"] + } + }, + "required": ["accessPort", "bizScenario"] + }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "accessPort": { + "type": "string", + "description": "接入端口名称,唯一标识被投诉的专线对象" + }, + "bizScenario": { + "type": "string", + "description": "投诉分类场景,仅允许取值:专线中断、专线质差", + "enum": ["专线中断", "专线质差"] + } + }, + "required": ["accessPort", "bizScenario"] + }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "promptText": { + "$fromStep": 1, + "$field": "promptText" + } + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { "errCode": "", "errMessage": "" } + }, + { + "result": "success", + "data": { + "accessPort": "P850-琶洲-PTN7900-12-TPA1EG08-03", + "bizScenario": "专线质差" + }, + "error": { "errCode": "", "errMessage": "" } + } + ] + } +] \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/private-line-complaint/input_case_from_text.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/private-line-complaint/input_case_from_text.json new file mode 100644 index 00000000..853b9c48 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/private-line-complaint/input_case_from_text.json @@ -0,0 +1,251 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-意图清晰参数完整", + "caseDesc": "基础档-专线业务中断-接入端口名称-标准", + "caseCategory": "正常", + "input": [ + { + "api": "generateTaskPromptFromText", + "args": { + "text": "发生专线业务中断,专线业务对应的接入端口名称为P781-珠江新城-PTN7900-23-TPA1EG24-17,业务投诉工单流水号为fault-id-1-017-20230511-09013", + "templateUri": "Task-T/network-layer/private-line-complaint/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "accessPort": { + "type": "string", + "description": "接入端口名称,唯一标识被投诉的专线对象" + }, + "bizScenario": { + "type": "string", + "description": "投诉分类场景,仅允许取值:专线中断、专线质差", + "enum": ["专线中断", "专线质差"] + }, + "faultTime": { + "type": "string", + "description": "问题发生时间" + }, + "eventSerialNo": { + "type": "string", + "description": "OSS 侧受理的投诉工单或事件流水号" + }, + "faultDetail": { + "type": "string", + "description": "投诉/故障现象详情描述" + } + }, + "required": ["accessPort", "bizScenario"] + }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "promptText": { + "$fromStep": 1, + "$field": "promptText" + } + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { "errCode": "", "errMessage": "" } + }, + { + "result": "success", + "data": { + "accessPort": "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "bizScenario": "专线中断" + }, + "error": { "errCode": "", "errMessage": "" } + } + ] + }, + { + "id": "TC00000002", + "caseDimension": "02-模板地址错误", + "caseDesc": "传入不存在的模板 URI,生成失败并返回 template.not_found", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromText", + "args": { + "text": "专线业务中断,接入端口 P781-珠江新城-PTN7900-23-TPA1EG24-17", + "templateUri": "Task-T/network-layer/no-such-template/v1" + } + } + ], + "expected": [ + { + "result": "error", + "data": {}, + "error": { "errCode": "template.not_found", "errMessage": "" } + } + ] + }, + { + "id": "TC00000003", + "caseDimension": "03-校验非法输入", + "caseDesc": "生成成功后对空报文做校验,返回 negotiation.invalid_input", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromText", + "args": { + "text": "专线业务中断,接入端口 P781-珠江新城-PTN7900-23-TPA1EG24-17,投诉分类:专线中断", + "templateUri": "Task-T/network-layer/private-line-complaint/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "accessPort": { + "type": "string", + "description": "接入端口名称,唯一标识被投诉的专线对象" + } + }, + "required": ["accessPort"] + }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "promptText": "" + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { "errCode": "", "errMessage": "" } + }, + { + "result": "error", + "data": {}, + "error": { "errCode": "negotiation.invalid_input", "errMessage": "" } + } + ] + }, + { + "id": "TC00000004", + "caseDimension": "04-口语省略表达", + "caseDesc": "只给端口和'业务不通'等口语说法,要求仍能生成并提取接入端口", + "caseCategory": "边界", + "input": [ + { + "api": "generateTaskPromptFromText", + "args": { + "text": "我们那条线不通了,就是 P781-珠江新城-PTN7900-23-TPA1EG24-17 这个口,你们看看", + "templateUri": "Task-T/network-layer/private-line-complaint/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "accessPort": { + "type": "string", + "description": "接入端口名称,唯一标识被投诉的专线对象" + }, + "bizScenario": { + "type": "string", + "description": "投诉分类场景,仅允许取值:专线中断、专线质差", + "enum": ["专线中断", "专线质差"] + } + }, + "required": ["accessPort", "bizScenario"] + }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "promptText": { + "$fromStep": 1, + "$field": "promptText" + } + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { "errCode": "", "errMessage": "" } + }, + { + "result": "success", + "data": { + "accessPort": "P781-珠江新城-PTN7900-23-TPA1EG24-17" + }, + "error": { "errCode": "", "errMessage": "" } + } + ] + }, + { + "id": "TC00000005", + "caseDimension": "05-同义词模糊表达", + "caseDesc": "用'业务中断''无法访问'等同义词表达投诉,要求仍能生成并提取接入端口", + "caseCategory": "模糊语义", + "input": [ + { + "api": "generateTaskPromptFromText", + "args": { + "text": "专线业务中断了,业务无法访问,接入端口名称 P781-珠江新城-PTN7900-23-TPA1EG24-17", + "templateUri": "Task-T/network-layer/private-line-complaint/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "accessPort": { + "type": "string", + "description": "接入端口名称,唯一标识被投诉的专线对象" + }, + "bizScenario": { + "type": "string", + "description": "投诉分类场景,仅允许取值:专线中断、专线质差", + "enum": ["专线中断", "专线质差"] + } + }, + "required": ["accessPort", "bizScenario"] + }, + "templateUri": "Task-T/network-layer/private-line-complaint/v1", + "promptText": { + "$fromStep": 1, + "$field": "promptText" + } + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { "errCode": "", "errMessage": "" } + }, + { + "result": "success", + "data": { + "accessPort": "P781-珠江新城-PTN7900-23-TPA1EG24-17" + }, + "error": { "errCode": "", "errMessage": "" } + } + ] + } +] \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/ran-energy-saving-modify/input_case_from_data.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/ran-energy-saving-modify/input_case_from_data.json new file mode 100644 index 00000000..4f7aa2f3 --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/ran-energy-saving-modify/input_case_from_data.json @@ -0,0 +1,366 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-意图清晰参数完整", + "caseDesc": "修改档-结构化数据完整(操作类型+区域+目标+制式+时段),生成并校验提取", + "caseCategory": "正常", + "input": [ + { + "api": "generateTaskPromptFromDataWithSchema", + "args": { + "data": { + "operationType": "modify", + "region": "松山湖管委会", + "energyTarget": "30%", + "energyTargetDirection": "reduce", + "cellMode": "NR", + "startTime": "00:00:00+08:00", + "endTime": "12:00:00+08:00" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": ["create", "modify"] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": ["reduce", "increase"] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": ["operationType", "region"] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": ["create", "modify"] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": ["reduce", "increase"] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": ["operationType", "region"] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1", + "promptText": { + "$fromStep": 1, + "$field": "promptText" + } + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { "errCode": "", "errMessage": "" } + }, + { + "result": "success", + "data": { + "operationType": "modify", + "region": "松山湖管委会", + "startTime": "16:00:00Z", + "endTime": "04:00:00Z", + "energyTarget": "30%", + "energyTargetDirection": "reduce" + }, + "error": { "errCode": "", "errMessage": "" } + } + ] + }, + { + "id": "TC00000002", + "caseDimension": "04-关键信息缺失", + "caseDesc": "结构化数据缺少节能区域字段,生成侧校验应拦截", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromDataWithSchema", + "args": { + "data": { + "operationType": "modify", + "cellMode": "NR", + "startTime": "00:00:00+08:00", + "endTime": "12:00:00+08:00" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": ["create", "modify"] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": ["reduce", "increase"] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": ["operationType", "region"] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + } + ], + "expected": [ + { + "result": "error", + "data": {}, + "error": { "errCode": "slot.not_provided", "errMessage": "" } + } + ] + }, + { + "id": "TC00000003", + "caseDimension": "05-意图描述模糊", + "caseDesc": "结构化数据节能目标未按量化格式(百分比)提供(口语化模糊表述),服务端语义校验应拒绝", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromDataWithSchema", + "args": { + "data": { + "operationType": "modify", + "region": "松山湖管委会", + "energyTarget": "再往下压一压", + "cellMode": "NR", + "startTime": "00:00:00+08:00", + "endTime": "12:00:00+08:00" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": ["create", "modify"] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": ["reduce", "increase"] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": ["operationType", "region"] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": ["create", "modify"] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": ["reduce", "increase"] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": ["operationType", "region"] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1", + "promptText": { + "$fromStep": 1, + "$field": "promptText" + } + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { "errCode": "", "errMessage": "" } + }, + { + "result": "error", + "data": {}, + "error": { "errCode": "negotiation.semantic_rejected", "errMessage": "" } + } + ] + }, + { + "id": "TC00000004", + "caseDimension": "06-值域违规检测", + "caseDesc": "结构化数据操作类型“删除”不在创建/修改取值范围内,生成侧拒绝", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromDataWithSchema", + "args": { + "data": { + "operationType": "delete", + "region": "东莞松山湖片区" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": ["create", "modify"] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": ["reduce", "increase"] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": ["operationType", "region"] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + } + ], + "expected": [ + { + "result": "error", + "data": {}, + "error": { "errCode": "slot.constraint_violated", "errMessage": "" } + } + ] + } +] \ No newline at end of file diff --git a/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/ran-energy-saving-modify/input_case_from_text.json b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/ran-energy-saving-modify/input_case_from_text.json new file mode 100644 index 00000000..2444509d --- /dev/null +++ b/a2a-t-corpus/src/test/java/net/openan/a2at/sdk/corpus/suites/task/resources/ran-energy-saving-modify/input_case_from_text.json @@ -0,0 +1,199 @@ +[ + { + "id": "TC00000001", + "caseDimension": "01-意图清晰参数完整", + "caseDesc": "修改档-无线网络节能-标准修改诉求(区域+量化目标+制式+时段,北京时间输入→期望UTC归一化HH:mm:ssZ)", + "caseCategory": "正常", + "input": [ + { + "api": "generateTaskPromptFromText", + "args": { + "text": "把东莞松山湖片区的无线网络节能任务调整为修改:节能的小区制式:NR,需要节能的时间范围(北京时间,UTC+8):起始时间“00:00:00”,结束时间“12:00:00”,能耗目标:总功耗降低30%", + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": ["create", "modify"] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": ["reduce", "increase"] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": ["operationType", "region"] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1", + "promptText": { + "$fromStep": 1, + "$field": "promptText" + } + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { "errCode": "", "errMessage": "" } + }, + { + "result": "success", + "data": { + "operationType": "modify", + "region": "东莞松山湖片区", + "startTime": "16:00:00Z", + "endTime": "04:00:00Z", + "energyTarget": "30%", + "energyTargetDirection": "reduce" + }, + "error": { "errCode": "", "errMessage": "" } + } + ] + }, + { + "id": "TC00000002", + "caseDimension": "04-关键信息缺失", + "caseDesc": "修改诉求缺少节能区域,校验required应拦截", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromText", + "args": { + "text": "修改无线网络节能任务,节能的小区制式:NR,需要节能的时间范围(北京时间,UTC+8):起始时间“00:00:00”,结束时间“12:00:00”", + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + } + ], + "expected": [ + { + "result": "error", + "data": {}, + "error": { "errCode": "slot.not_provided", "errMessage": "" } + } + ] + }, + { + "id": "TC00000003", + "caseDimension": "05-意图描述模糊", + "caseDesc": "节能目标未按量化格式(百分比)提供(口语化模糊表述),服务端语义校验应拒绝", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromText", + "args": { + "text": "把松山湖管委会的无线网络节能任务调整为修改:节能的小区制式:NR,需要节能的时间范围(北京时间,UTC+8):起始时间“00:00:00”,结束时间“12:00:00”,能耗目标:再往下压一压", + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + }, + { + "api": "validateTaskPromptAndDataFilling", + "args": { + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "description": "Operation type of the RAN energy-saving task; allowed values are listed in the enum", + "enum": ["create", "modify"] + }, + "region": { + "type": "string", + "description": "Coverage region of the energy-saving task; must be a specific physical area name, e.g. Songshan Lake Management Committee" + }, + "energyTarget": { + "type": "string", + "description": "Energy-saving target, exactly one of two mutually exclusive legal forms: (1) quantitative - a pure percentage number, e.g. 30% (direction is expressed by energyTargetDirection); (2) qualitative - 在满足任务上下文的条件下进行最大化节能. When composing the task target, keep the user stated value verbatim. When extracting or validating: if a target value is stated but matches neither legal form (e.g. vague statements like 再往下压一压), report a format error; if no target is stated at all, extract null without error (the field is optional). Example: user text 总功耗降低30% maps to energyTarget=30%." + }, + "energyTargetDirection": { + "type": "string", + "description": "Direction of the energy target, derived from user wording: 降低/下降/压降 → reduce; 提升/上升 → increase. If no direction word appears in the task, default to reduce (energy-saving tasks reduce the metric by definition).", + "enum": ["reduce", "increase"] + }, + "cellMode": { + "type": "string", + "description": "Cell mode the energy saving applies to; one of LTE, NR, or 全部 (all)" + }, + "startTime": { + "type": "string", + "description": "Daily start time of the energy-saving window. Values carry an explicit UTC offset (e.g. 00:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 起始时间00:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 00:00:00 stated as 北京时间 (UTC+8) → 16:00:00Z) and report a format error when no timezone is stated." + }, + "endTime": { + "type": "string", + "description": "Daily end time of the energy-saving window. Values carry an explicit UTC offset (e.g. 12:00:00+08:00, 北京时间). When composing the task context, state the time together with its timezone (e.g. 结束时间12:00:00+08:00) and never strip the timezone; when extracting, normalize to UTC in 24-hour HH:mm:ssZ format (e.g. 12:00:00 stated as 北京时间 (UTC+8) → 04:00:00Z) and report a format error when no timezone is stated." + } + }, + "required": ["operationType", "region"] + }, + "templateUri": "Task-T/network-layer/ran-energy-saving/v1", + "promptText": { + "$fromStep": 1, + "$field": "promptText" + } + } + } + ], + "expected": [ + { + "result": "success", + "data": {}, + "error": { "errCode": "", "errMessage": "" } + }, + { + "result": "error", + "data": {}, + "error": { "errCode": "negotiation.semantic_rejected", "errMessage": "" } + } + ] + }, + { + "id": "TC00000004", + "caseDimension": "06-值域违规检测", + "caseDesc": "操作类型“删除”不在创建/修改取值范围内", + "caseCategory": "异常", + "input": [ + { + "api": "generateTaskPromptFromText", + "args": { + "text": "删除东莞松山湖片区的无线网络节能任务,该任务已不再需要", + "templateUri": "Task-T/network-layer/ran-energy-saving/v1" + } + } + ], + "expected": [ + { + "result": "error", + "data": {}, + "error": { "errCode": "slot.constraint_violated", "errMessage": "" } + } + ] + } +] \ No newline at end of file diff --git a/a2a-t-corpus/tools/csv-templates/input-case-template.csv b/a2a-t-corpus/tools/csv-templates/input-case-template.csv new file mode 100644 index 00000000..043314de --- /dev/null +++ b/a2a-t-corpus/tools/csv-templates/input-case-template.csv @@ -0,0 +1,2 @@ +id,caseDimension,caseDesc,caseCategory,scenario,step1_api,step1_args,step1_expect_result,step1_expect_data,step1_expect_errCode,step1_expect_errMessageContains,step2_api,step2_args,step2_expect_result,step2_expect_data,step2_expect_errCode,step2_expect_errMessageContains,step3_api,step3_args,step3_expect_result,step3_expect_data,step3_expect_errCode,step3_expect_errMessageContains +TC00000001,01-意图清晰参数完整,示例:专线业务中断标准投诉(可删除本行),正常,private-line-complaint,generateTaskPromptFromText,"{""text"":""发生专线业务中断,接入端口名称为P781-……"",""templateUri"":""Task-T/network-layer/private-line-complaint/v1""}",success,{},,,validateTaskPromptAndDataFilling,"{""schema"":{""$schema"":""https://json-schema.org/draft/2020-12/schema"",""type"":""object"",""properties"":{""accessPort"":{""type"":""string""}},""required"":[""accessPort""]},""templateUri"":""Task-T/network-layer/private-line-complaint/v1"",""promptText"":{""$fromStep"":1,""$field"":""promptText""}}",success,"{""accessPort"":""P781-……""}",,,,,,,, diff --git a/a2a-t-corpus/tools/inputCsvToJson.py b/a2a-t-corpus/tools/inputCsvToJson.py new file mode 100644 index 00000000..2bd577c8 --- /dev/null +++ b/a2a-t-corpus/tools/inputCsvToJson.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""inputCsvToJson - convert the case design table (CSV) to corpus input JSON, and back. + +Subcommands (mutually exclusive): + --template write the case-design template CSV (with one example row) to --out + --reverse convert an input_case_*.json back to the design table (CSV) + +CSV contract: one row per case; `stepN_*` column pairs map 1:1 to the input/expected +array elements, so any step count is supported. All files are UTF-8; the template +ships with a BOM so Excel opens it directly. + +Validation (--no-validate to disable) mirrors the Java case loader: id uniqueness +inside the file, non-empty metadata columns, parseable JSON fragments for args and +per-step expected data, and api/expect presence per step. +""" + +import argparse +import csv +import json +import re +import sys +from pathlib import Path + +FIXED_COLUMNS = ["id", "caseDimension", "caseDesc", "caseCategory"] +STEP_PREFIX_RE = re.compile(r"^(?Pstep(?P\d+)_(?Papi|args|expect_result|expect_data|expect_errCode|expect_errMessageContains))$") +UTF8_BOM = "\ufeff" + + +def parse_step_columns(header): + """Maps column name -> (step number, field) for all stepN_* columns.""" + steps = [] + for column in header: + match = STEP_PREFIX_RE.match(column) + if match: + steps.append((int(match.group("num")), match.group("field"), column)) + return sorted(steps, key=lambda item: (item[0], item[1])) + + +def fail(message): + print(f"[error] {message}", file=sys.stderr) + sys.exit(1) + + +def load_rows(path): + with open(path, "r", encoding="utf-8-sig", newline="") as handle: + return list(csv.DictReader(handle)) + + +def parse_json_fragment(column, row_id, allow_empty=False): + text = (column or "").strip() + if not text: + if allow_empty: + return {} + fail(f"case {row_id}: missing JSON fragment") + try: + value = json.loads(text) + except json.JSONDecodeError as error: + fail(f"case {row_id}: invalid JSON fragment: {error}") + if not allow_empty and not isinstance(value, dict): + fail(f"case {row_id}: JSON fragment must be an object: {text}") + return value + + +def rows_to_cases(rows, requested_scenario=None): + if not rows: + fail("no data rows in CSV") + step_columns = parse_step_columns(list(rows[0].keys())) + cases = [] + seen_ids = set() + for row in rows: + row_id = (row.get("id") or "").strip() + if not row_id: + fail("found a data row without id") + if row_id in seen_ids: + fail(f"duplicate case id: {row_id}") + seen_ids.add(row_id) + if not row.get("caseDimension", "").strip(): + fail(f"case {row_id}: missing caseDimension") + if not row.get("caseDesc", "").strip(): + fail(f"case {row_id}: missing caseDesc") + if not row.get("caseCategory", "").strip(): + fail(f"case {row_id}: missing caseCategory") + + scenario = (row.get("scenario") or "").strip() or requested_scenario + if scenario: + row["scenario"] = scenario + else: + del row["scenario"] + + max_step = max((num for num, _, _ in step_columns), default=0) + input_steps = [] + expected_steps = [] + for num in range(1, max_step + 1): + api = (row.get(f"step{num}_api") or "").strip() + args = (row.get(f"step{num}_args") or "").strip() + result = (row.get(f"step{num}_expect_result") or "").strip() + if not api and not args and not result: + continue + if not api: + fail(f"case {row_id}: step{num} missing api") + if result not in ("success", "error"): + fail(f"case {row_id}: step{num}_expect_result must be success or error: {result}") + input_steps.append({ + "api": api, + "args": parse_json_fragment(args, row_id) if args else {}, + }) + expected_step = { + "result": result, + "data": parse_json_fragment(row.get(f"step{num}_expect_data"), row_id, allow_empty=True), + "error": { + "errCode": (row.get(f"step{num}_expect_errCode") or "").strip(), + "errMessage": (row.get(f"step{num}_expect_errMessageContains") or "").strip(), + }, + } + contains = (row.get(f"step{num}_expect_errMessageContains") or "").strip() + if contains: + expected_step["error"]["errMessageContains"] = contains + expected_steps.append(expected_step) + if not input_steps: + fail(f"case {row_id}: at least one step is required") + if len(input_steps) != len(expected_steps): + fail(f"case {row_id}: input/expected step count mismatch") + + case = { + "id": row_id, + "caseDimension": row.get("caseDimension").strip(), + "caseDesc": row.get("caseDesc").strip(), + "caseCategory": row.get("caseCategory").strip(), + "input": input_steps, + "expected": expected_steps, + } + cases.append((scenario, case)) + return cases + + +def write_template(path): + header = FIXED_COLUMNS + ["scenario"] + for num in (1, 2, 3): + header += [ + f"step{num}_api", f"step{num}_args", + f"step{num}_expect_result", f"step{num}_expect_data", + f"step{num}_expect_errCode", f"step{num}_expect_errMessageContains", + ] + example = { + "id": "TC00000001", + "caseDimension": "01-意图清晰参数完整", + "caseDesc": "示例:专线业务中断标准投诉(可删除本行)", + "caseCategory": "正常", + "scenario": "private-line-complaint", + "step1_api": "generateTaskPromptFromText", + "step1_args": '{"text":"发生专线业务中断,接入端口名称为P781-……","templateUri":"Task-T/network-layer/private-line-complaint/v1"}', + "step1_expect_result": "success", + "step1_expect_data": "{}", + "step1_expect_errCode": "", + "step1_expect_errMessageContains": "", + "step2_api": "validateTaskPromptAndDataFilling", + "step2_args": '{"schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"accessPort":{"type":"string"}},"required":["accessPort"]},"templateUri":"Task-T/network-layer/private-line-complaint/v1","promptText":{"$fromStep":1,"$field":"promptText"}}', + "step2_expect_result": "success", + "step2_expect_data": '{"accessPort":"P781-……"}', + "step2_expect_errCode": "", + "step2_expect_errMessageContains": "", + "step3_api": "", + "step3_args": "", + "step3_expect_result": "", + "step3_expect_data": "", + "step3_expect_errCode": "", + "step3_expect_errMessageContains": "", + } + with open(path, "w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=header) + writer.writeheader() + writer.writerow(example) + print(f"Wrote case design template: {path}") + + +def cases_to_json(cases, api_names=None): + out = [] + for _, case in cases: + serialized = { + "id": case["id"], + "caseDimension": case["caseDimension"], + "caseDesc": case["caseDesc"], + "caseCategory": case["caseCategory"], + "input": case["input"], + "expected": case["expected"], + } + out.append(serialized) + return out + + +def json_to_rows(cases): + rows = [] + step_columns = build_step_columns(cases) + header = FIXED_COLUMNS + ["scenario"] + step_columns + for case in cases: + row = {key: case.get(key, "") for key in FIXED_COLUMNS} + row["scenario"] = case.get("scenario", "") + for num in range(1, len(case.get("input", [])) + 1): + step = case["input"][num - 1] + expected = case["expected"][num - 1] if num - 1 < len(case.get("expected", [])) else {} + row[f"step{num}_api"] = step.get("api", "") + row[f"step{num}_args"] = json.dumps(step.get("args", {}), ensure_ascii=False) + row[f"step{num}_expect_result"] = expected.get("result", "") + row[f"step{num}_expect_data"] = json.dumps(expected.get("data", {}) or {}, ensure_ascii=False) + error = expected.get("error") or {} + row[f"step{num}_expect_errCode"] = error.get("errCode", "") + row[f"step{num}_expect_errMessageContains"] = error.get("errMessageContains", "") + for column in step_columns: + row.setdefault(column, "") + rows.append(row) + return header, rows + + +def build_step_columns(cases): + max_steps = max((len(case.get("input", [])) for case in cases), default=0) + columns = [] + for num in range(1, max_steps + 1): + columns += [ + f"step{num}_api", f"step{num}_args", + f"step{num}_expect_result", f"step{num}_expect_data", + f"step{num}_expect_errCode", f"step{num}_expect_errMessageContains", + ] + return columns + + +def write_csv(path, header, rows): + with open(path, "w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=header) + writer.writeheader() + writer.writerows(rows) + print(f"Wrote CSV: {path}") + + +def main(): + parser = argparse.ArgumentParser(description="Case design CSV <-> corpus input JSON converter") + parser.add_argument("--template", action="store_true", help="write the design template CSV (with one example row) to --out") + parser.add_argument("--reverse", action="store_true", help="reverse direction: input JSON -> design CSV") + parser.add_argument("--csv", help="input CSV (default command) or output CSV (--reverse)") + parser.add_argument("--out", required=True, help="output file") + parser.add_argument("--scenario", help="target scenario name (written to the scenario column; inferred from the output file name when absent)") + parser.add_argument("--no-validate", action="store_true", help="skip structural validation of the produced JSON") + args = parser.parse_args() + + if args.template: + write_template(args.out) + return + + if args.reverse: + if not args.csv: + fail("--reverse requires --csv pointing at an input_case_*.json") + with open(args.csv, "r", encoding="utf-8-sig") as handle: + cases = json.load(handle) + header, rows = json_to_rows(cases) + write_csv(args.out, header, rows) + return + + if not args.csv: + fail("--csv pointing at the design table is required (or use --template to generate one)") + rows = load_rows(args.csv) + scenario = args.scenario + if not scenario: + scenario = Path(args.out).stem.replace("input_case_", "") or None + cases = rows_to_cases(rows, scenario) + + payload = cases_to_json(cases) + if not args.no_validate: + validate_payload(payload) + with open(args.out, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + print(f"Wrote case JSON: {args.out} ({len(payload)} cases)") + + +def validate_payload(payload): + if not isinstance(payload, list) or not payload: + fail("validation failed: the top level must be a case array") + for case in payload: + case_id = case.get("id") + for key in FIXED_COLUMNS: + if not str(case.get(key, "")).strip(): + fail(f"validation failed: case {case_id} missing {key}") + if not isinstance(case.get("input"), list) or not case["input"]: + fail(f"validation failed: case {case_id} input must be a non-empty array") + if not isinstance(case.get("expected"), list) or len(case["expected"]) != len(case["input"]): + fail(f"validation failed: case {case_id} expected must be the same length as input") + for i, step in enumerate(case["input"], start=1): + if not isinstance(step, dict) or not str(step.get("api", "")).strip(): + fail(f"validation failed: case {case_id} step {i} missing api") + expected = case["expected"][i - 1] + if expected.get("result") not in ("success", "error"): + fail(f"validation failed: case {case_id} step {i} expected.result invalid") + if expected.get("result") == "error" and not str( + (expected.get("error") or {}).get("errCode", "")).strip(): + fail(f"validation failed: case {case_id} step {i} error expectation missing errCode") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/a2a-t-corpus/tools/outputJsonToCsv.py b/a2a-t-corpus/tools/outputJsonToCsv.py new file mode 100644 index 00000000..d0e30be0 --- /dev/null +++ b/a2a-t-corpus/tools/outputJsonToCsv.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""outputJsonToCsv - render corpus result JSON into one review-friendly CSV table (one row per case). + +Every step exposes its API request and response verbatim plus every underlying LLM +request/response; the content is always complete, never truncated. `stepN_*` column +pairs extend with the case step count, and the API error code is surfaced per step. + +Usage: + python outputJsonToCsv.py --json output_result_from_text.json --out result.csv + python outputJsonToCsv.py --json

//output_result_from_text.json,/output_result_from_text.json --out merged.csv +""" + +import argparse +import csv +import json +import sys +from pathlib import Path + +UTF8_BOM = "\ufeff" + + +def fail(message): + print(f"[error] {message}", file=sys.stderr) + sys.exit(1) + + +def load_results(paths): + records = [] + for raw_path in paths: + path = Path(raw_path) + if not path.is_file(): + fail(f"result file not found: {path}") + scenario = path.parent.name + with open(path, "r", encoding="utf-8-sig") as handle: + payload = json.load(handle) + if not isinstance(payload, list): + fail(f"{path} top level must be a result array") + for record in payload: + record["__scenario"] = scenario + records.append(record) + return records + + +def split_steps(interactions): + """Splits the interleaved transcript into per-step groups {api, interaction, llm_calls}.""" + steps = [] + for interaction in interactions: + if interaction.get("type") == "LLM": + if steps: + steps[-1]["llm_calls"].append(interaction) + continue + steps.append({"api": interaction.get("type", ""), "interaction": interaction, "llm_calls": []}) + return steps + + +def jsonize(value, pretty=True): + if value is None: + return "" + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False, indent=2 if pretty else None) + + +def records_to_rows(records): + all_rows = [] + max_steps = 0 + normalized = [] + for record in records: + interactions = (record.get("output") or {}).get("interactions") or [] + steps = split_steps(interactions) + normalized.append((record, steps)) + max_steps = max(max_steps, len(steps)) + for record, steps in normalized: + row = { + "scenario": record.get("__scenario", ""), + "id": record.get("id", ""), + "caseDimension": record.get("caseDimension", ""), + "caseDesc": record.get("caseDesc", ""), + "caseCategory": record.get("caseCategory", ""), + "result": record.get("result", ""), + "totalTime_ms": record.get("totalTime", 0), + "totalInputToken": record.get("totalInputToken", 0), + "totalOutputToken": record.get("totalOutputToken", 0), + "totalToken": record.get("totalToken", 0), + } + for num, step in enumerate(steps, start=1): + interaction = step["interaction"] + prefix = f"step{num}_" + row[prefix + "api"] = step["api"] + row[prefix + "result"] = interaction.get("result", "") + row[prefix + "duration_ms"] = interaction.get("duration_ms", 0) + row[prefix + "inputToken"] = interaction.get("inputToken", 0) + row[prefix + "outputToken"] = interaction.get("outputToken", 0) + row[prefix + "api_errCode"] = (interaction.get("response") or {}).get("errorCode", "") + row[prefix + "request"] = jsonize(interaction.get("request")) + row[prefix + "response"] = jsonize(interaction.get("response")) + row[prefix + "llm_requests"] = jsonize([call.get("request") for call in step["llm_calls"]]) + row[prefix + "llm_responses"] = jsonize([call.get("response") for call in step["llm_calls"]]) + row["fail_reason"] = record.get("failReason", "") + row["__max_steps"] = len(steps) + all_rows.append(row) + return all_rows, max_steps + + +def build_header(max_steps): + header = [ + "scenario", "id", "caseDimension", "caseDesc", "caseCategory", + "result", "totalTime_ms", "totalInputToken", "totalOutputToken", "totalToken", + ] + for num in range(1, max_steps + 1): + header += [ + f"step{num}_api", f"step{num}_result", f"step{num}_duration_ms", + f"step{num}_inputToken", f"step{num}_outputToken", f"step{num}_api_errCode", + f"step{num}_request", f"step{num}_response", + f"step{num}_llm_requests", f"step{num}_llm_responses", + ] + header.append("fail_reason") + return header + + +def main(): + parser = argparse.ArgumentParser(description="Corpus result JSON -> review CSV (one row per case with full per-step request/response)") + parser.add_argument("--json", required=True, help="output_result_*.json; comma-separated to merge several scenarios") + parser.add_argument("--out", required=True, help="output CSV") + args = parser.parse_args() + + records = load_results([item.strip() for item in args.json.split(",") if item.strip()]) + rows, max_steps = records_to_rows(records) + header = build_header(max_steps) + with open(args.out, "w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=header) + writer.writeheader() + for row in rows: + writer.writerow({key: row.get(key, "") for key in header}) + print(f"Wrote review table: {args.out} ({len(rows)} rows, {max_steps} step(s) max, complete request/response output)") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/a2a-t-llm/pom.xml b/a2a-t-llm/pom.xml index 72dea5d8..2c0f1991 100644 --- a/a2a-t-llm/pom.xml +++ b/a2a-t-llm/pom.xml @@ -29,10 +29,20 @@ openai-java-core ${openai.version} + + org.slf4j + slf4j-api + org.junit.jupiter junit-jupiter test + + ch.qos.logback + logback-classic + 1.4.14 + test + diff --git a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java index 05fc10e1..74da6e04 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMClientConfig.java @@ -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. * @@ -13,6 +21,12 @@ * @param timeoutSeconds optional provider timeout in seconds * @param sessionMaxTotal reserved total session limit * @param sessionMaxPerProvider reserved per-provider session limit + * @param disableSystemProxy whether to bypass the JVM or operating-system HTTP proxy + * @param sslVerify whether to verify the TLS certificate chain and hostname of the LLM endpoint + * @param detailLogEnabled whether to print the full LLM request and response payloads (no truncation); summary logs + * (timestamp, token usage, elapsed time) are recorded at DEBUG level independently of this flag + * @param reasoningEffort optional reasoning effort for reasoning models (none/minimal/low/medium/high/xhigh); null + * leaves the parameter unset * @since 2026-06 */ public record LLMClientConfig( @@ -25,4 +39,140 @@ public record LLMClientConfig( Double temperature, Double timeoutSeconds, int sessionMaxTotal, - int sessionMaxPerProvider) {} + int sessionMaxPerProvider, + boolean disableSystemProxy, + boolean sslVerify, + boolean detailLogEnabled, + 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"); + + /** Pre-{@code sslVerify} signature kept for binary compatibility; TLS peer verification stays enabled. */ + public LLMClientConfig( + String provider, + String model, + String apiKey, + String baseUrl, + int historyWindow, + Integer maxTokens, + Double temperature, + Double timeoutSeconds, + int sessionMaxTotal, + int sessionMaxPerProvider, + boolean disableSystemProxy, + String reasoningEffort) { + this( + provider, + model, + apiKey, + baseUrl, + historyWindow, + maxTokens, + temperature, + timeoutSeconds, + sessionMaxTotal, + sessionMaxPerProvider, + disableSystemProxy, + true, + false, + reasoningEffort); + } + + /** + * 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.sessionMaxTotal(), + llmConfig.sessionMaxPerProvider(), + llmConfig.disableSystemProxy(), + llmConfig.sslVerify(), + llmConfig.detailLogEnabled(), + 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 0841d928..00000000 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/LLMConfigLoader.java +++ /dev/null @@ -1,133 +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; - - 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"); - - 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, - sessionMaxTotal, - sessionMaxPerProvider); - } - - 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 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; - } -} 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-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java index 85698acc..fd7d8c4e 100644 --- a/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java +++ b/a2a-t-llm/src/main/java/net/openan/a2at/sdk/llm/providers/OpenAIClient.java @@ -3,27 +3,39 @@ 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; 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.security.SecureRandom; +import java.security.cert.X509Certificate; import java.time.Duration; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.function.BiFunction; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; import net.openan.a2at.sdk.llm.LLMClient; import net.openan.a2at.sdk.llm.LLMClientConfig; import net.openan.a2at.sdk.llm.LLMConfigError; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** - * OpenAI-compatible LLM provider client. + * OpenAI LLM provider client. * * @since 2026-06 */ @@ -31,6 +43,15 @@ public class OpenAIClient implements LLMClient { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final Logger log = LoggerFactory.getLogger(OpenAIClient.class); + + /** + * Dedicated logger for structured {@code llm_call} request/response logging (DEBUG level). Separate from the module + * logger so embedding applications can filter LLM call logs independently, e.g. + * {@code logging.level.net.openan.a2at.sdk.llm.call=DEBUG}. + */ + private static final Logger CALL_LOG = LoggerFactory.getLogger("net.openan.a2at.sdk.llm.call"); + private static final String JSON_MODE_INSTRUCTION = "Return a valid JSON object string. The output must be valid json. " + "Do not wrap the response in markdown code fences. " @@ -48,8 +69,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"); } @@ -63,11 +83,33 @@ public LLMResponse structured( if (config.baseUrl() == null || config.baseUrl().isBlank()) { throw new LLMConfigError(config.provider() + " client requires a non-empty base_url"); } + ChatCompletionCreateParams params = buildStructuredParams(messages, jsonSchema, temperature, maxTokens); + long startNanos = System.nanoTime(); + boolean debugEnabled = CALL_LOG.isDebugEnabled(); + if (debugEnabled) { + logRequest( + messages, + jsonSchema, + params.messages().size(), + temperature == null ? config.temperature() : temperature, + maxTokens == null ? config.maxTokens() : maxTokens); + } try { - return parseResponse(executor.apply(config, buildStructuredParams(messages, jsonSchema, temperature, maxTokens))); + ChatCompletion response = executor.apply(config, params); + LLMResponse parsed = parseResponse(response); + if (debugEnabled) { + logResponse(startNanos, response); + } + return parsed; } catch (LLMConfigError | LLMRuntimeError error) { + if (debugEnabled) { + logError(startNanos, error); + } throw error; } catch (Exception error) { + if (debugEnabled) { + logError(startNanos, error); + } throw new LLMRuntimeError(config.provider() + " invocation failed: " + error.getMessage(), error); } } @@ -87,6 +129,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(); } @@ -116,21 +161,28 @@ private static ChatCompletionMessageParam systemMessage(String content) { ChatCompletionSystemMessageParam.builder().content(content).build()); } - private static String toJson(Map jsonSchema) { + private static String toJson(Object value) { try { - return OBJECT_MAPPER.writeValueAsString(jsonSchema); + return OBJECT_MAPPER.writeValueAsString(value); } catch (JsonProcessingException error) { throw new LLMRuntimeError("Failed to serialize JSON schema", error); } } 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) { String rawContent = extractMessageText(response); try { + if (rawContent == null || rawContent.isBlank()) { + throw new LLMRuntimeError(config.provider() + + " returned empty content (finish_reason=" + extractFinishReason(response) + + "): typically rate limiting, a request timeout, or max_tokens exhausted by reasoning" + + " content - check model quotas and the MAX_TOKENS/TIMEOUT settings"); + } Object parsed = OBJECT_MAPPER.readValue(rawContent, Object.class); if (!(parsed instanceof Map)) { throw new LLMRuntimeError(config.provider() + " must return a JSON object string"); @@ -143,12 +195,25 @@ private String extractJsonObjectString(ChatCompletion response) { } } + private static String extractFinishReason(ChatCompletion response) { + if (response.choices().isEmpty()) { + return "unknown"; + } + Object finishReason = response.choices().get(0).finishReason(); + return finishReason == null ? "none" : finishReason.toString(); + } + private String extractMessageText(ChatCompletion response) { if (response.choices().isEmpty()) { throw new LLMRuntimeError(config.provider() + " response did not include any choices"); } - 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 (finish_reason=" + + extractFinishReason(response) + ")")); } private static Map mapUsage(ChatCompletion response) { @@ -159,8 +224,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; } @@ -171,22 +239,133 @@ private static Map mapMetadata(ChatCompletion response) { return metadata; } + private void logRequest( + List> messages, + Map jsonSchema, + int messageCount, + Double resolvedTemperature, + Integer resolvedMaxTokens) { + String schemaText = "Return JSON that conforms to this JSON schema: " + toJson(jsonSchema); + List> fullMessages = new ArrayList<>(messages.size() + 2); + fullMessages.add(Map.of("role", "system", "content", JSON_MODE_INSTRUCTION)); + fullMessages.add(Map.of("role", "system", "content", schemaText)); + fullMessages.addAll(messages); + String messagesJson = toJson(fullMessages); + CALL_LOG.debug( + "llm_call event=request ts={} provider={} model={} messages={} chars={} temperature={} max_tokens={}", + utcNow(), + config.provider(), + config.model(), + messageCount, + messagesJson.length(), + resolvedTemperature == null ? "-" : resolvedTemperature, + resolvedMaxTokens == null ? "-" : resolvedMaxTokens); + if (config.detailLogEnabled()) { + CALL_LOG.debug( + "llm_call event=request_body ts={} provider={} model={} messages_json={}", + utcNow(), + config.provider(), + config.model(), + messagesJson); + } + } + + private void logResponse(long startNanos, ChatCompletion response) { + String contentText = extractMessageText(response); + Map usage = mapUsage(response); + CALL_LOG.debug( + "llm_call event=response ts={} provider={} model={} elapsed_ms={} prompt_tokens={} completion_tokens={} total_tokens={} content_chars={} response_id={}", + utcNow(), + config.provider(), + response.model(), + elapsedMs(startNanos), + usage.get("prompt_tokens"), + usage.get("completion_tokens"), + usage.get("total_tokens"), + contentText.length(), + response.id()); + if (config.detailLogEnabled()) { + CALL_LOG.debug( + "llm_call event=response_body ts={} provider={} model={} content={}", + utcNow(), + config.provider(), + response.model(), + contentText); + } + } + + private void logError(long startNanos, Throwable error) { + CALL_LOG.debug( + "llm_call event=error ts={} provider={} model={} elapsed_ms={} error_code={} error={}", + utcNow(), + config.provider(), + config.model(), + elapsedMs(startNanos), + error.getClass().getSimpleName(), + error.getMessage()); + } + + private static String elapsedMs(long startNanos) { + return String.format(Locale.ROOT, "%.1f", (System.nanoTime() - startNanos) / 1_000_000.0d); + } + + private static String utcNow() { + return DateTimeFormatter.ISO_INSTANT.format(Instant.now().truncatedTo(ChronoUnit.MILLIS)); + } + @SuppressWarnings("deprecation") private ChatCompletion executeWithSdk(LLMClientConfig runtimeConfig, ChatCompletionCreateParams params) { return sdkClient(runtimeConfig).chat().completions().create(params); } @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.disableSystemProxy()) { + builder.proxy(Proxy.NO_PROXY); + } + if (!runtimeConfig.sslVerify()) { + applyInsecureTls(builder); + } + if (runtimeConfig.timeoutSeconds() != null && runtimeConfig.timeoutSeconds() > 0.0d) { + builder.timeout(Duration.ofMillis(Math.max(1L, Math.round(runtimeConfig.timeoutSeconds() * 1000.0d)))); + } + sdkClient = builder.build(); } - sdkClient = builder.build(); return sdkClient; } + + /** + * Disables TLS certificate-chain and hostname verification for the provider endpoint. + * + *

Only for controlled environments whose HTTPS endpoint cannot yet present a certificate trusted by the JVM. + * Everything else on the connection keeps the OpenAI SDK defaults. + */ + private static void applyInsecureTls(OpenAIOkHttpClient.Builder builder) { + X509TrustManager trustAllManager = new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[] {trustAllManager}, new SecureRandom()); + builder.sslSocketFactory(sslContext.getSocketFactory()); + } catch (Exception error) { + throw new LLMConfigError("Failed to disable TLS certificate-chain and hostname verification", error); + } + builder.trustManager(trustAllManager); + builder.hostnameVerifier((hostname, session) -> true); + log.warn("[A2AT-LLM] TLS certificate chain and hostname verification are disabled; " + + "use only in controlled environments with trusted networks"); + } } diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java new file mode 100644 index 00000000..b5f7bbf2 --- /dev/null +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigFromTest.java @@ -0,0 +1,311 @@ +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, + 500, + 200, + false, + true, + false, + "medium", + 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(true, config.sslVerify()); + assertEquals(false, config.detailLogEnabled()); + assertEquals("medium", config.reasoningEffort()); + assertEquals(500, config.sessionMaxTotal()); + assertEquals(200, config.sessionMaxPerProvider()); + } + + @Test + void carriesDetailLogEnabledThroughFrom() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, true, null, 3, + List.of()); + + LLMClientConfig config = LLMClientConfig.from(llmConfig); + + assertEquals(true, config.detailLogEnabled()); + } + + @Test + void carriesSslVerifyDisabledThroughFrom() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, false, false, null, 3, + List.of()); + + LLMClientConfig config = LLMClientConfig.from(llmConfig); + + assertEquals(false, config.sslVerify()); + } + + @Test + void legacyLlmConfigConstructorKeepsSslVerifyEnabled() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, null, 3, List.of()); + + LLMClientConfig config = LLMClientConfig.from(llmConfig); + + assertEquals(true, config.sslVerify()); + assertEquals(false, config.detailLogEnabled()); + } + + @Test + void rejectsMissingProvider() { + LlmConfig llmConfig = new LlmConfig( + "", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, false, null, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("provider")); + } + + @Test + void rejectsMissingModel() { + LlmConfig llmConfig = new LlmConfig( + "openai", "", "sk-test", null, 10, null, null, null, 300, 100, false, true, false, null, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("model")); + } + + @Test + void rejectsMissingApiKey() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "", null, 10, null, null, null, 300, 100, false, true, false, null, 3, List.of()); + + LLMConfigError error = assertThrows(LLMConfigError.class, () -> LLMClientConfig.from(llmConfig)); + + assertTrue(error.getMessage().contains("apiKey")); + } + + @Test + void rejectsMultipleMissingKeysAndListsThemAll() { + LlmConfig llmConfig = + new LlmConfig("", "", "", null, 10, null, null, null, 300, 100, false, true, false, null, 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, 300, 100, false, true, false, null, 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, 3001, 100, false, true, false, null, 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, 300, 1001, false, true, false, null, 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, 50, 100, false, true, false, null, 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, 300, 100, false, true, false, null, 3, + List.of()); + + LLMClientConfig config = LLMClientConfig.from(llmConfig); + + assertNull(config.baseUrl()); + assertNull(config.maxTokens()); + assertNull(config.temperature()); + assertNull(config.timeoutSeconds()); + assertEquals(false, config.disableSystemProxy()); + assertEquals(true, config.sslVerify()); + assertNull(config.reasoningEffort()); + } + + @Test + void normalizesReasoningEffortCase() { + LlmConfig llmConfig = new LlmConfig( + "openai", "gpt-4o", "sk-test", null, 10, null, null, null, 300, 100, false, true, false, "MEDIUM", 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, 300, 100, false, true, false, "middle", 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, 300, 100, false, true, false, " ", 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, 300, 100, false, true, false, null, 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, + 300, + 100, + false, + true, + false, + null, + 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, 500, 200, false, true, false, "medium", 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, 300, 100, false, true, false, null, 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, 0, 100, false, true, false, null, 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, 300, 0, false, true, false, null, 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)); + } +} 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..58f6c07f 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientConfigTest.java @@ -18,7 +18,11 @@ void storesResolvedDefaultLlmClientConfiguration() { 0.2d, 15.5d, 300, - 100); + 100, + true, + false, + false, + "none"); assertEquals("openai", config.provider()); assertEquals("gpt-4o-mini", config.model()); @@ -28,7 +32,31 @@ void storesResolvedDefaultLlmClientConfiguration() { assertEquals(1024, config.maxTokens()); assertEquals(0.2d, config.temperature()); assertEquals(15.5d, config.timeoutSeconds()); + assertEquals(true, config.disableSystemProxy()); + assertEquals(false, config.sslVerify()); + assertEquals(false, config.detailLogEnabled()); assertEquals(300, config.sessionMaxTotal()); assertEquals(100, config.sessionMaxPerProvider()); + assertEquals("none", config.reasoningEffort()); + } + + @Test + void legacyConstructorDefaultsSslVerifyToTrue() { + LLMClientConfig config = new LLMClientConfig( + "openai", + "gpt-4o-mini", + "sk-test", + "https://api.example.test/v1", + 10, + 1024, + 0.2d, + 15.5d, + 300, + 100, + true, + "none"); + + assertEquals(true, config.sslVerify()); + assertEquals(false, config.detailLogEnabled()); } } diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java index 5eb42b23..2de1b1ef 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMClientFactoryTest.java @@ -82,7 +82,8 @@ void rejectsProviderClassWithoutPublicConfigConstructor() { } private static LLMClientConfig buildConfig(String provider) { - return new LLMClientConfig(provider, "test-model", "sk-test", null, 10, null, null, null, 300, 100); + return new LLMClientConfig( + provider, "test-model", "sk-test", null, 10, null, null, null, 300, 100, false, true, false, null); } public static final class CustomClient implements LLMClient { diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java deleted file mode 100644 index 6a955bf4..00000000 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/LLMConfigLoaderTest.java +++ /dev/null @@ -1,131 +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(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_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(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 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")); - } - - 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..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 @@ -22,7 +22,6 @@ void llmRootPackageExposesOnlySimplifiedPublicApi() throws IOException { "LLMClientConfig.java", "LLMClientFactory.java", "LLMConfigError.java", - "LLMConfigLoader.java", "LLMError.java", "LLMResponse.java", "LLMRuntimeError.java", @@ -70,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()); @@ -80,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-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..bb3504e9 100644 --- a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTest.java @@ -5,28 +5,57 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import com.fasterxml.jackson.databind.node.TextNode; import com.openai.core.JsonValue; import com.openai.models.chat.completions.ChatCompletion; 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; import net.openan.a2at.sdk.llm.LLMResponse; import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; class OpenAIClientTest { + private final ListAppender callLogAppender = new ListAppender<>(); + + private final Logger callLogger = (Logger) LoggerFactory.getLogger("net.openan.a2at.sdk.llm.call"); + + @BeforeEach + void attachCallLogAppender() { + callLogAppender.start(); + callLogger.addAppender(callLogAppender); + } + + @AfterEach + void detachCallLogAppender() { + callLogger.detachAppender(callLogAppender); + callLogger.setLevel(null); + } + @Test void rejectsBlankApiKeyOnConstruction() { - LLMConfigError error = assertThrows(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 +66,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 +77,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,14 +104,17 @@ 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 @@ -100,7 +130,11 @@ void structuredFallsBackToConfigTemperatureAndMaxTokens() { 0.4d, null, 300, - 100); + 100, + false, + true, + false, + null); OpenAIClient client = new OpenAIClient(config, (runtimeConfig, requestParams) -> { capturedParams.set(requestParams); return chatCompletion("{}"); @@ -115,10 +149,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 +163,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,15 +183,211 @@ 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) { - 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, false, true, false, null); + } + + private static LLMClientConfig config(String apiKey, String baseUrl, boolean detailLogEnabled) { + return new LLMClientConfig( + "openai", + "gpt-4o-mini", + apiKey, + baseUrl, + 10, + null, + null, + null, + 300, + 100, + false, + true, + detailLogEnabled, + null); + } + + // ---- Detailed call logging (dedicated logger net.openan.a2at.sdk.llm.call at DEBUG) ---- + + @Test + void summaryLogsRecordedAtDebugWithoutPayloadWhenDetailLogDisabled() { + OpenAIClient client = new OpenAIClient( + config("sk-secret-summary", "https://api.example.test/v1", false), + (runtimeConfig, requestParams) -> chatCompletion("{\"device_type\":\"router\"}")); + + client.structured( + List.of(Map.of("role", "user", "content", "extract router")), Map.of("type", "object"), 0.25d, 9); + + List messages = recordedMessages(); + assertTrue(messages.stream().anyMatch(line -> line.startsWith("llm_call event=request "))); + assertTrue(messages.stream().anyMatch(line -> line.startsWith("llm_call event=response "))); + assertTrue(messages.stream().noneMatch(line -> line.contains("event=request_body"))); + assertTrue(messages.stream().noneMatch(line -> line.contains("event=response_body"))); + + String requestLine = messages.stream() + .filter(line -> line.startsWith("llm_call event=request ")) + .findFirst() + .orElseThrow(); + assertContains( + requestLine, + "provider=openai", + "model=gpt-4o-mini", + "messages=3", + "chars=", + "temperature=0.25", + "max_tokens=9"); + + String responseLine = messages.stream() + .filter(line -> line.startsWith("llm_call event=response ")) + .findFirst() + .orElseThrow(); + assertContains( + responseLine, + "provider=openai", + "model=gpt-4o-mini", + "elapsed_ms=", + "prompt_tokens=7", + "completion_tokens=2", + "total_tokens=9", + "content_chars=", + "response_id=chatcmpl_123"); + + assertTrue( + messages.stream().noneMatch(line -> line.contains("sk-secret-summary")), + "logs must not leak the API key"); + } + + @Test + void payloadLinesRecordedWithoutTruncationWhenDetailLogEnabled() { + OpenAIClient client = new OpenAIClient( + config("sk-test", "https://api.example.test/v1", true), + (runtimeConfig, requestParams) -> chatCompletion("{\"device_type\":\"router\"}")); + + client.structured( + List.of(Map.of("role", "user", "content", "extract the device type here please")), + Map.of("type", "object", "properties", Map.of("device_type", Map.of("type", "string"))), + null, + null); + + List messages = recordedMessages(); + String requestBodyLine = messages.stream() + .filter(line -> line.contains("event=request_body")) + .findFirst() + .orElseThrow(); + assertContains( + requestBodyLine, + "extract the device type here please", + "device_type", + "Return a valid JSON object string"); + String responseBodyLine = messages.stream() + .filter(line -> line.contains("event=response_body")) + .findFirst() + .orElseThrow(); + assertContains(responseBodyLine, "{\"device_type\":\"router\"}"); + } + + @Test + void errorLineRecordedOnProviderFailure() { + OpenAIClient client = new OpenAIClient( + config("sk-test", "https://api.example.test/v1", false), (runtimeConfig, requestParams) -> { + throw new IllegalStateException("provider unavailable"); + }); + + assertThrows( + LLMRuntimeError.class, + () -> client.structured( + List.of(Map.of("role", "user", "content", "extract")), Map.of("type", "object"), null, null)); + + List messages = recordedMessages(); + String errorLine = messages.stream() + .filter(line -> line.startsWith("llm_call event=error ")) + .findFirst() + .orElseThrow(); + assertContains(errorLine, "elapsed_ms=", "error_code=IllegalStateException", "error=provider unavailable"); + assertTrue(!errorLine.contains("prompt_tokens"), "token fields must not appear on the error line"); + assertTrue(messages.stream().noneMatch(line -> line.contains("event=response"))); + } + + @Test + void noCallLogsWhenDedicatedLoggerNotEnabledForDebug() { + callLogger.setLevel(Level.INFO); + + OpenAIClient client = new OpenAIClient( + config("sk-test", "https://api.example.test/v1", true), (runtimeConfig, requestParams) -> { + return chatCompletion("{}"); + }); + + client.structured(List.of(Map.of("role", "user", "content", "extract")), Map.of("type", "object"), null, null); + + assertTrue( + recordedMessages().isEmpty(), + "no llm_call logs expected when the dedicated logger is not enabled for DEBUG"); + } + + private List recordedMessages() { + return callLogAppender.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void assertContains(String message, String... fragments) { + for (String fragment : fragments) { + assertTrue(message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); + } } private static ChatCompletion chatCompletion(String content) { diff --git a/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTlsVerificationTest.java b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTlsVerificationTest.java new file mode 100644 index 00000000..176c54d7 --- /dev/null +++ b/a2a-t-llm/src/test/java/net/openan/a2at/sdk/llm/providers/OpenAIClientTlsVerificationTest.java @@ -0,0 +1,149 @@ +package net.openan.a2at.sdk.llm.providers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsServer; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.junit.jupiter.api.Test; + +/** + * Behavioral TLS tests against a local HTTPS server that presents a self-signed certificate for {@code localhost} while + * the client connects to {@code 127.0.0.1}, so the default path fails on both trust and hostname checks. + */ +class OpenAIClientTlsVerificationTest { + + private static final String CHAT_COMPLETION_BODY = + "{\"id\":\"chatcmpl_test\",\"object\":\"chat.completion\",\"created\":1," + + "\"model\":\"gpt-4o-mini\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\"," + + "\"content\":\"{\\\"device_type\\\":\\\"router\\\"}\"},\"finish_reason\":\"stop\"}]," + + "\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":2,\"total_tokens\":9}}"; + + @Test + void skipsPeerCertificateVerificationWhenDisabled() throws Exception { + try (SelfSignedHttpsServer server = SelfSignedHttpsServer.start()) { + OpenAIClient client = new OpenAIClient(config(server, false)); + + LLMResponse response = client.structured( + List.of(Map.of("role", "user", "content", "extract")), Map.of("type", "object"), null, null); + + assertEquals("{\"device_type\":\"router\"}", response.content()); + assertEquals(1, server.requestCount()); + } + } + + @Test + void verifiesPeerCertificateByDefault() throws Exception { + try (SelfSignedHttpsServer server = SelfSignedHttpsServer.start()) { + OpenAIClient client = new OpenAIClient(config(server, true)); + + LLMRuntimeError error = assertThrows( + LLMRuntimeError.class, + () -> client.structured( + List.of(Map.of("role", "user", "content", "extract")), + Map.of("type", "object"), + null, + null)); + + assertTrue( + hasSslFailureInCauseChain(error), + "expected an SSLException in the cause chain but got: " + error.getCause()); + assertEquals(0, server.requestCount()); + } + } + + private static LLMClientConfig config(SelfSignedHttpsServer server, boolean sslVerify) { + return new LLMClientConfig( + "openai", + "gpt-4o-mini", + "sk-test", + server.baseUrl() + "/v1", + 10, + null, + null, + 5.0d, + 300, + 100, + false, + sslVerify, + false, + null); + } + + private static boolean hasSslFailureInCauseChain(Throwable error) { + for (Throwable current = error; current != null; current = current.getCause()) { + if (current instanceof SSLException) { + return true; + } + } + return false; + } + + private static final class SelfSignedHttpsServer implements AutoCloseable { + + private final HttpsServer server; + private final AtomicInteger requests = new AtomicInteger(); + + private SelfSignedHttpsServer(HttpsServer server) { + this.server = server; + } + + static SelfSignedHttpsServer start() throws Exception { + HttpsServer server = HttpsServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.setHttpsConfigurator(new HttpsConfigurator(serverSslContext())); + SelfSignedHttpsServer wrapper = new SelfSignedHttpsServer(server); + server.createContext("/", exchange -> { + exchange.getRequestBody().readAllBytes(); + wrapper.requests.incrementAndGet(); + byte[] body = CHAT_COMPLETION_BODY.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + try (var out = exchange.getResponseBody()) { + out.write(body); + } + }); + server.start(); + return wrapper; + } + + String baseUrl() { + return "https://127.0.0.1:" + server.getAddress().getPort(); + } + + int requestCount() { + return requests.get(); + } + + @Override + public void close() { + server.stop(0); + } + + private static SSLContext serverSslContext() throws Exception { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (InputStream input = + OpenAIClientTlsVerificationTest.class.getResourceAsStream("/self-signed-llm-test-keystore.p12")) { + keyStore.load(input, "changeit".toCharArray()); + } + KeyManagerFactory factory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + factory.init(keyStore, "changeit".toCharArray()); + SSLContext context = SSLContext.getInstance("TLS"); + context.init(factory.getKeyManagers(), null, null); + return context; + } + } +} diff --git a/a2a-t-llm/src/test/resources/self-signed-llm-test-keystore.p12 b/a2a-t-llm/src/test/resources/self-signed-llm-test-keystore.p12 new file mode 100644 index 00000000..9bf4c468 Binary files /dev/null and b/a2a-t-llm/src/test/resources/self-signed-llm-test-keystore.p12 differ diff --git a/a2a-t-negotiation/pom.xml b/a2a-t-negotiation/pom.xml index 296ac98f..dc9c8e14 100644 --- a/a2a-t-negotiation/pom.xml +++ b/a2a-t-negotiation/pom.xml @@ -19,10 +19,42 @@ a2a-t-core ${project.version} + + net.openan.a2a-t.sdk + a2a-t-llm + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-prompt + ${project.version} + + + org.slf4j + slf4j-api + + + 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 + + + ch.qos.logback + logback-classic + 1.4.14 + test + 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 new file mode 100644 index 00000000..51404ed4 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityEndingContent.java @@ -0,0 +1,11 @@ +package net.openan.a2at.sdk.negotiation.content; + +/** + * Content of a feasibility negotiation terminal message. + * + * @param conclusion terminal conclusion of the feasibility negotiation + * @param feasibilitySummary required summary of the feasibility evaluation result + * @since 2026-08 + */ +public record FeasibilityEndingContent(NegotiationConclusion conclusion, String feasibilitySummary) + implements NegotiationEndingContent {} 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 new file mode 100644 index 00000000..08683c2f --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/FeasibilityProposeContent.java @@ -0,0 +1,28 @@ +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/InformationEndingContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationEndingContent.java new file mode 100644 index 00000000..9e290586 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationEndingContent.java @@ -0,0 +1,13 @@ +package net.openan.a2at.sdk.negotiation.content; + +import java.util.List; + +/** + * Content of an information negotiation terminal message. + * + * @param conclusion terminal conclusion of the information negotiation + * @param items information items delivered with the terminal message + * @since 2026-08 + */ +public record InformationEndingContent(NegotiationConclusion conclusion, List items) + implements NegotiationEndingContent {} 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 new file mode 100644 index 00000000..01406c0d --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/InformationProposeContent.java @@ -0,0 +1,13 @@ +package net.openan.a2at.sdk.negotiation.content; + +import java.util.List; + +/** + * Content of an information negotiation propose message. + * + * @param items information items the requester is missing + * @param relationship free-form description of how the missing items relate to each other; null when there is none + * @since 2026-08 + */ +public record InformationProposeContent(List items, String relationship) + implements NegotiationProposeContent {} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortContent.java new file mode 100644 index 00000000..69ef699e --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortContent.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 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 new file mode 100644 index 00000000..dc727b4f --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAbortData.java @@ -0,0 +1,15 @@ +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. + * + *

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, NegotiationAbortContent content) {} 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 new file mode 100644 index 00000000..2bfbf66f --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationAction.java @@ -0,0 +1,18 @@ +package net.openan.a2at.sdk.negotiation.content; + +/** + * Action a feasibility negotiation propose message performs. + * + *

The action is not rendered into the message; it selects which conditional sections of the feasibility propose + * template are emitted. + * + * @since 2026-08 + */ +public enum NegotiationAction { + + /** Asks the counterpart to evaluate whether the request is feasible. */ + REQUEST_FEASIBILITY_EVALUATION, + + /** Reports that the request is infeasible and proposes an alternative. */ + PROPOSE_ALTERNATIVE_ON_FAILURE +} 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 new file mode 100644 index 00000000..a2ba389c --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationConclusion.java @@ -0,0 +1,37 @@ +package net.openan.a2at.sdk.negotiation.content; + +/** + * Terminal outcome of a negotiation as expressed in negotiation result sections. + * + *

Only {@code ACCEPT} and {@code REJECT} are renderable conclusions of the typed negotiation templates; the + * {@code ABORT} outcome is rendered through the type-independent common abort template, whose fixed conclusion section + * carries no conclusion slot, so the typed generation methods reject it as a programming error. + * + * @since 2026-08 + */ +public enum NegotiationConclusion { + + /** The negotiation parties reached an agreement. */ + ACCEPT("Accept"), + + /** The negotiation parties did not reach an agreement. */ + REJECT("Reject"), + + /** The negotiation was abandoned outside the template outcome model. */ + ABORT("Abort"); + + private final String literal; + + NegotiationConclusion(String literal) { + this.literal = literal; + } + + /** + * Returns the literal text filled into negotiation result slots. + * + * @return literal conclusion text such as {@code Accept} + */ + public String literal() { + return literal; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java new file mode 100644 index 00000000..51aceef3 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationContent.java @@ -0,0 +1,13 @@ +package net.openan.a2at.sdk.negotiation.content; + +/** + * Marker for the typed content of any negotiation message, regardless of phase. + * + *

The concrete families are {@link NegotiationProposeContent} for propose-phase messages, + * {@link NegotiationEndingContent} for terminal accept/reject messages and {@link NegotiationAbortContent} for the + * type-independent abort message. Generators and content extractors accept this common supertype and dispatch on the + * exact runtime type. + * + * @since 2026-08 + */ +public sealed interface NegotiationContent permits NegotiationProposeContent, NegotiationEndingContent, NegotiationAbortContent {} 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 new file mode 100644 index 00000000..b709f012 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingContent.java @@ -0,0 +1,17 @@ +package net.openan.a2at.sdk.negotiation.content; + +/** + * Marker for the typed content of a terminal (accept or reject) negotiation message. + * + * @since 2026-08 + */ +public sealed interface NegotiationEndingContent extends NegotiationContent + permits InformationEndingContent, TargetEndingContent, FeasibilityEndingContent { + + /** + * Returns the terminal conclusion this content carries. + * + * @return negotiation conclusion + */ + NegotiationConclusion conclusion(); +} 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 new file mode 100644 index 00000000..d7b0afce --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationEndingData.java @@ -0,0 +1,17 @@ +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/NegotiationGenerationException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java new file mode 100644 index 00000000..d29e21c3 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationGenerationException.java @@ -0,0 +1,62 @@ +package net.openan.a2at.sdk.negotiation.content; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import org.jspecify.annotations.Nullable; + +/** + * Raised when generating a negotiation message fails at runtime. + * + * @since 2026-08 + */ +public class NegotiationGenerationException extends NegotiationProcessingException { + + /** + * Creates a negotiation generation failure with one error code. + * + * @param code machine-readable error code for the failure + * @param message failure message + */ + public NegotiationGenerationException(String code, String message) { + super(code, message); + } + + /** + * Creates a negotiation generation failure with one error code and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param cause root cause + */ + public NegotiationGenerationException(String code, String message, @Nullable Throwable cause) { + super(code, message, cause); + } + + /** + * Creates a negotiation generation failure whose message is rendered from the code's template in one language. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + */ + public NegotiationGenerationException( + ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { + super(entry, language, facts); + } + + /** + * Creates a negotiation generation failure with a rendered message and a root cause. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param cause root cause + */ + public NegotiationGenerationException( + ErrorCatalog entry, + @Nullable String language, + @Nullable Map facts, + @Nullable Throwable cause) { + super(entry, language, facts, cause); + } +} 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 new file mode 100644 index 00000000..a5e8dd86 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationItem.java @@ -0,0 +1,10 @@ +package net.openan.a2at.sdk.negotiation.content; + +/** + * One named entry of a negotiation item list. + * + * @param name item name such as a field path or identifier; never blank in valid content + * @param value free-form explanation of the item such as meaning, format, example or reason; may be null + * @since 2026-08 + */ +public record NegotiationItem(String name, String value) {} 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 new file mode 100644 index 00000000..f730641a --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationParamExtractionException.java @@ -0,0 +1,120 @@ +package net.openan.a2at.sdk.negotiation.content; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import org.jspecify.annotations.Nullable; + +/** + * Raised when validating a negotiation message and extracting parameters from it fails. + * + * @since 2026-08 + */ +public class NegotiationParamExtractionException extends A2ATBusinessException { + + private final List errors; + + /** + * Creates a negotiation parameter-extraction failure with one specific error code and slot details. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + */ + public NegotiationParamExtractionException(String code, String message, List errors) { + super(code, message); + this.errors = errors == null ? List.of() : List.copyOf(errors); + } + + /** + * Creates a negotiation parameter-extraction failure with one specific error code, slot details and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param errors structured per-slot validation error details + * @param cause root cause of the failure + */ + public NegotiationParamExtractionException( + String code, String message, List errors, @Nullable Throwable cause) { + super(code, message); + if (cause != null) { + initCause(cause); + } + this.errors = errors == null ? List.of() : List.copyOf(errors); + } + + /** + * Creates a negotiation parameter-extraction failure carrying one catalog code, an already rendered message, its + * fact values, slot details and a root cause. + * + *

Unlike the {@code ErrorCatalog}-rendering constructors, this constructor keeps the message exactly as passed + * in, so a message rendered upstream (for example by the shared validation pipeline) survives the wrap unchanged + * while the facts stay available to callers. + * + * @param code machine-readable error code for the failure + * @param message already rendered failure message + * @param facts fact values keyed by fact parameter name, may be null + * @param errors structured per-slot validation error details + * @param cause root cause of the failure + */ + public NegotiationParamExtractionException( + String code, + String message, + @Nullable Map facts, + List errors, + @Nullable Throwable cause) { + super(code, message, facts, cause); + this.errors = errors == null ? List.of() : List.copyOf(errors); + } + + /** + * Creates a negotiation parameter-extraction failure whose message is rendered from the code's template in one + * language. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param errors structured per-slot validation error details + */ + public NegotiationParamExtractionException( + ErrorCatalog entry, + @Nullable String language, + @Nullable Map facts, + List errors) { + this(entry, language, facts, errors, null); + } + + /** + * Creates a negotiation parameter-extraction failure with a rendered message, slot details and a root cause. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param errors structured per-slot validation error details + * @param cause root cause of the failure + */ + public NegotiationParamExtractionException( + ErrorCatalog entry, + @Nullable String language, + @Nullable Map facts, + List errors, + @Nullable Throwable cause) { + super(entry.getCode(), ErrorMessages.render(entry, language, facts), facts); + if (cause != null) { + initCause(cause); + } + this.errors = errors == null ? List.of() : List.copyOf(errors); + } + + /** + * Returns the structured per-slot validation error details. + * + * @return immutable list of slot validation errors, never null + */ + public List getErrors() { + return errors; + } +} 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 new file mode 100644 index 00000000..7a762bb1 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProcessingException.java @@ -0,0 +1,76 @@ +package net.openan.a2at.sdk.negotiation.content; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import org.jspecify.annotations.Nullable; + +/** + * Base failure type of the generation branch, raised when a negotiation generation step fails at runtime. + * + *

Each failure carries a machine-readable code from {@link ErrorCatalog} — inherited from the + * {@link A2ATBusinessException} base — and a message rendered from the code's message template, so callers can branch + * on the failure class without parsing messages. The parameter-extraction branch + * ({@code NegotiationParamExtractionException}) is deliberately not a subtype; catch {@link A2ATBusinessException} for + * full negotiation business-failure coverage. + * + * @since 2026-08 + */ +public class NegotiationProcessingException extends A2ATBusinessException { + + /** + * Creates a negotiation processing failure with one error code. + * + * @param code machine-readable error code for the failure + * @param message failure message + */ + public NegotiationProcessingException(String code, String message) { + super(code, message); + } + + /** + * Creates a negotiation processing failure with one error code and a root cause. + * + * @param code machine-readable error code for the failure + * @param message failure message + * @param cause root cause + */ + public NegotiationProcessingException(String code, String message, @Nullable Throwable cause) { + super(code, message); + if (cause != null) { + initCause(cause); + } + } + + /** + * Creates a negotiation processing failure whose message is rendered from the code's template in one language. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + */ + public NegotiationProcessingException( + ErrorCatalog entry, @Nullable String language, @Nullable Map facts) { + super(entry.getCode(), ErrorMessages.render(entry, language, facts), facts); + } + + /** + * Creates a negotiation processing failure with a rendered message and a root cause. + * + * @param entry catalog entry identifying the failure + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + * @param facts fact values keyed by fact parameter name, may be null + * @param cause root cause + */ + public NegotiationProcessingException( + ErrorCatalog entry, + @Nullable String language, + @Nullable Map facts, + @Nullable Throwable cause) { + super(entry.getCode(), ErrorMessages.render(entry, language, facts), facts); + if (cause != null) { + initCause(cause); + } + } +} 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 new file mode 100644 index 00000000..a32989f7 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeContent.java @@ -0,0 +1,9 @@ +package net.openan.a2at.sdk.negotiation.content; + +/** + * Marker for the typed content of a propose-phase negotiation message. + * + * @since 2026-08 + */ +public sealed interface NegotiationProposeContent extends NegotiationContent + permits InformationProposeContent, TargetProposeContent, FeasibilityProposeContent {} 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 new file mode 100644 index 00000000..1be87f3c --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationProposeData.java @@ -0,0 +1,12 @@ +package net.openan.a2at.sdk.negotiation.content; + +import net.openan.a2at.sdk.core.model.NegotiationContext; + +/** + * Input bundle for generating a propose-phase negotiation message from typed data. + * + * @param context negotiation session context + * @param content typed propose content matching the negotiation type addressed by the template URI + * @since 2026-08 + */ +public record NegotiationProposeData(NegotiationContext context, NegotiationProposeContent content) {} 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 new file mode 100644 index 00000000..c8f4b970 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/NegotiationType.java @@ -0,0 +1,36 @@ +package net.openan.a2at.sdk.negotiation.content; + +/** + * Kind of negotiation a negotiation message belongs to. + * + *

The enum also owns the hyphenated URI segment used both in template URIs and in template resource paths, so that + * URIs and on-disk locations can never drift apart. + * + * @since 2026-08 + */ +public enum NegotiationType { + + /** Negotiation about missing information the requester needs. */ + INFORMATION("information-negotiation"), + + /** Negotiation about aligning the target and intent of a request. */ + TARGET("target-negotiation"), + + /** Negotiation about whether a request can be fulfilled at all. */ + FEASIBILITY("feasibility-negotiation"); + + private final String typeSegment; + + NegotiationType(String typeSegment) { + this.typeSegment = typeSegment; + } + + /** + * Returns the hyphenated URI segment identifying this negotiation type. + * + * @return URI segment such as {@code information-negotiation} + */ + public String typeSegment() { + return typeSegment; + } +} 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 new file mode 100644 index 00000000..2558b5fa --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetEndingContent.java @@ -0,0 +1,15 @@ +package net.openan.a2at.sdk.negotiation.content; + +/** + * Content of a target negotiation terminal message. + * + *

Exactly one of {@code confirmedIntent} and {@code failureReason} is meaningful, selected by the conclusion: an + * accept message must carry the confirmed intent, a reject message must carry the failure reason. + * + * @param conclusion terminal conclusion of the target negotiation + * @param confirmedIntent confirmed intent for an accept conclusion; null otherwise + * @param failureReason reason for a reject conclusion; null otherwise + * @since 2026-08 + */ +public record TargetEndingContent(NegotiationConclusion conclusion, String confirmedIntent, String failureReason) + implements NegotiationEndingContent {} 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 new file mode 100644 index 00000000..7351231c --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/TargetProposeContent.java @@ -0,0 +1,27 @@ +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 new file mode 100644 index 00000000..1281255b --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/Vocabulary.java @@ -0,0 +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) {} +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/package-info.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/package-info.java new file mode 100644 index 00000000..766779eb --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/content/package-info.java @@ -0,0 +1,2 @@ +/** Typed content models, vocabulary and failure types for negotiation messages. */ +package net.openan.a2at.sdk.negotiation.content; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbortGenerator.java new file mode 100644 index 00000000..227bc1cc --- /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.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 new file mode 100644 index 00000000..7066d0d1 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/AbstractNegotiationGenerator.java @@ -0,0 +1,142 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Shared plumbing of the six negotiation generators. + * + *

Subclasses validate their exact content type, assemble the slot map and hand over to the shared renderers. The + * slot map is keyed by the language-specific slot names taken from the vocabulary. + */ +abstract class AbstractNegotiationGenerator implements NegotiationGenerator { + + private final NegotiationPromptRenderer promptRenderer = new NegotiationPromptRenderer(); + + private final NegotiationItemFormatter itemFormatter = new NegotiationItemFormatter(); + + /** + * Casts the content to the exact type this generator serves. + * + * @param expected content type + * @param content content object validated by the registry + * @param expectedType exact content class this generator serves + * @param generatorName human-readable generator name used in the failure message + * @return content cast to the expected type + * @throws IllegalArgumentException if the content is null or of another runtime type + */ + protected static T contentOf( + NegotiationContent content, Class expectedType, String generatorName) { + if (content == null || content.getClass() != expectedType) { + throw new IllegalArgumentException( + generatorName + " requires content of type " + expectedType.getSimpleName() + " but received " + + (content == null ? "null" : content.getClass().getSimpleName()) + "."); + } + return expectedType.cast(content); + } + + /** + * Validates that a conclusion is renderable by a typed ending generator, rejecting null and the abort conclusion. + * + * @param conclusion conclusion carried by ending content + * @return the validated conclusion + * @throws NullPointerException if the conclusion is null + * @throws IllegalArgumentException if the conclusion is abort; the abort message has no typed conclusion slot and + * is generated through the abort phase against the common abort template instead + */ + protected static NegotiationConclusion renderableConclusion(NegotiationConclusion conclusion) { + Objects.requireNonNull( + conclusion, + "Negotiation conclusion must not be null; accept and reject are the renderable" + + " conclusions of a typed negotiation."); + if (conclusion == NegotiationConclusion.ABORT) { + throw new IllegalArgumentException( + "Typed negotiation templates carry no Abort conclusion slot; generate the abort message of a" + + " terminated negotiation with generateAbortFromData against the common abort template" + + " instead."); + } + return conclusion; + } + + /** + * Validates that a required text field is present. + * + * @param value field value + * @param field field path used in the failure facts + * @param description field description used in the failure reason + * @param vocabulary vocabulary of the message language, used to render the failure message + * @return the validated text + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the value is null or + * blank + */ + protected static String requiredText(String value, String field, String description, Vocabulary vocabulary) { + if (value == null || value.isBlank()) { + throw contentInvalid(field, description + " must not be blank.", vocabulary); + } + return value; + } + + /** + * Validates that a required item list is present and non-empty. + * + * @param items item list + * @param field field path used in the failure facts + * @param description field description used in the failure reason + * @param vocabulary vocabulary of the message language, used to render the failure message + * @return the validated item list + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the list is null or + * empty + */ + protected static List requiredItems( + List items, String field, String description, Vocabulary vocabulary) { + if (items == null || items.isEmpty()) { + throw contentInvalid(field, description + " must contain at least one item.", vocabulary); + } + return items; + } + + /** + * Builds the coded failure for invalid negotiation content data of a from-data generation. + * + * @param field content field path + * @param reason failure reason + * @param vocabulary vocabulary of the message language, used to render the failure message + * @return generation failure carrying the code {@code negotiation.content_invalid} + */ + protected static NegotiationGenerationException contentInvalid(String field, String reason, Vocabulary vocabulary) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_CONTENT_INVALID, + vocabulary.language(), + Map.of("field", field, "reason", reason)); + } + + /** + * Formats an item list with the list punctuation of the message language. + * + * @param items items to format; null formats as an empty value + * @param vocabulary vocabulary of the message language + * @return formatted numbered lines, or an empty string when there is no item + */ + protected String formatItems(List items, Vocabulary vocabulary) { + return itemFormatter.format(items, vocabulary.get("punct.list_colon")); + } + + /** + * Renders the template with the assembled slots. + * + * @param template loaded template to render + * @param slots slot values keyed by language-specific slot names + * @return rendered negotiation message text + */ + protected String render(PromptTemplate template, Map slots) { + return promptRenderer.render(template.content(), slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java new file mode 100644 index 00000000..be746ae3 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/DefaultNegotiationContentExtractor.java @@ -0,0 +1,444 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.json.JacksonJsonValueParser; +import net.openan.a2at.sdk.core.json.JsonValueParser; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default content extractor backed by one structured LLM call. + * + *

The extractor loads the system and user prompts of the addressed negotiation type from the classpath, asks the LLM + * for the snake_case JSON described by the extraction schema, and maps the response onto the typed content records. + * Failure mapping: a missing LLM client maps to {@code llm.not_configured}, transport failures map to + * {@code llm.invocation_failed}, responses that violate the response contract (empty or not a JSON object) map to the + * retryable {@code llm.response_invalid}, responses that cannot be mapped onto the expected content map to the + * retryable {@code negotiation.content_extract_failed}, missing required fields map to the non-retryable + * {@code negotiation.field_missing}, a conclusion contradicting the addressed phase maps to + * {@code negotiation.conclusion_mismatch}, and content that contradicts the addressed phase or action maps to + * {@code negotiation.invalid_input}. Every message is rendered from the code's message template in the reference + * language. + * + * @since 2026-08 + */ +final class DefaultNegotiationContentExtractor implements NegotiationContentExtractor { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationContentExtractor.class); + + private static final String CATEGORY_INFORMATION = "information_negotiation"; + + private static final String CATEGORY_TARGET = "target_negotiation"; + + private static final String CATEGORY_FEASIBILITY = "feasibility_negotiation"; + + private static final String CATEGORY_ABORT = "abort_negotiation"; + + private static final String EXTRACTION_STEP_EN = "negotiation content extraction"; + + private static final String EXTRACTION_STEP_ZH = "协商内容提取"; + + private final LLMClient llmClient; + + private final NegotiationMessageBuilder messageBuilder; + + private final NegotiationJsonSchemaBuilder schemaBuilder; + + private final JsonValueParser parser; + + /** + * Creates an extractor backed by one LLM client and the default message and schema builders. + * + * @param llmClient LLM client used for the structured content extraction call + */ + public DefaultNegotiationContentExtractor(LLMClient llmClient) { + this( + llmClient, + new NegotiationMessageBuilder(), + new NegotiationJsonSchemaBuilder(), + new JacksonJsonValueParser()); + } + + /** + * Creates an extractor with injectable collaborators. + * + * @param llmClient LLM client used for the structured content extraction call + * @param messageBuilder builder assembling the LLM messages of the extraction step + * @param schemaBuilder builder providing the extraction JSON Schema + * @param parser parser turning the LLM response content into a key-value map + */ + public DefaultNegotiationContentExtractor( + LLMClient llmClient, + NegotiationMessageBuilder messageBuilder, + NegotiationJsonSchemaBuilder schemaBuilder, + JsonValueParser parser) { + this.llmClient = llmClient; + this.messageBuilder = messageBuilder; + this.schemaBuilder = schemaBuilder; + this.parser = parser; + } + + /** + * Extracts the typed content of one negotiation message from free text. + * + * @param text free-text input describing the message content + * @param reference reference identifying the negotiation type, performative and language to extract for + * @return typed negotiation content matching the reference + * @throws NullPointerException if the reference is null + * @throws NegotiationGenerationException with one of the codes {@code llm.not_configured}, + * {@code llm.invocation_failed}, {@code llm.response_invalid}, {@code negotiation.content_extract_failed}, + * {@code negotiation.field_missing}, {@code negotiation.conclusion_mismatch} or + * {@code negotiation.invalid_input} + */ + @Override + public NegotiationContent extract(String text, NegotiationReference reference) { + Objects.requireNonNull(reference, "Negotiation reference must not be null."); + String language = reference.language(); + if (text == null || text.isBlank()) { + throw invalidInput(language, "Input text for negotiation content extraction must not be blank."); + } + Map tokens = Map.of( + NegotiationMessageBuilder.TOKEN_PHASE, + phaseToken(reference.performative()), + NegotiationMessageBuilder.TOKEN_INPUT, + text); + List> messages = + messageBuilder.buildMessages(promptCategory(reference.type()), reference.language(), tokens); + Map schema = schemaBuilder.buildExtractionSchema(reference.type(), reference.performative()); + Map payload = invokeLlm(messages, schema, language); + NegotiationContent content = mapContent(payload, reference.type(), reference.performative(), language); + LOGGER.atInfo().log( + "negotiation_content_extraction_completed type={} performative={}", + reference.type(), + reference.performative()); + return content; + } + + private Map invokeLlm( + List> messages, Map schema, String language) { + if (llmClient == null) { + throw new NegotiationGenerationException(ErrorCatalog.LLM_NOT_CONFIGURED, language, null); + } + LLMResponse response; + try { + response = llmClient.structured(messages, schema, null, null); + } catch (RuntimeException error) { + throw new NegotiationGenerationException( + ErrorCatalog.LLM_INVOCATION_FAILED, + language, + Map.of( + "provider", llmClient.getClass().getSimpleName(), + "reason", String.valueOf(error.getMessage())), + error); + } + String content = response == null ? null : response.content(); + if (content == null || content.isBlank()) { + throw responseInvalid(language, "Negotiation content extraction returned an empty response."); + } + try { + return parser.parseObject(content); + } catch (RuntimeException error) { + throw responseInvalid(language, "Negotiation content extraction response is not a JSON object."); + } + } + + private static NegotiationContent mapContent( + Map payload, + @Nullable NegotiationType type, + NegotiationPerformative performative, + String language) { + if (performative == NegotiationPerformative.ABORT) { + return new NegotiationAbortContent(requiredString(payload, "termination_reason", language)); + } + if (performative == NegotiationPerformative.PROPOSE) { + return mapProposeContent(payload, type, language); + } + return mapEndingContent(payload, type, performative, language); + } + + private static NegotiationContent mapProposeContent( + Map payload, NegotiationType type, String language) { + return switch (type) { + case INFORMATION -> new InformationProposeContent( + requiredNonEmptyItems(payload, "items", "information negotiation requested items", language), + optionalString(payload, "relationship", language)); + case TARGET -> mapTargetProposeContent(payload, language); + case FEASIBILITY -> mapFeasibilityProposeContent(payload, language); + }; + } + + private static NegotiationContent mapTargetProposeContent(Map payload, String language) { + String description = requiredString(payload, "target_negotiation_description", language); + List intentUnderstanding = optionalItems(payload, "intent_understanding", language); + List alignmentAndClarification = + optionalItems(payload, "alignment_and_clarification", language); + List requestForClarification = optionalItems(payload, "request_for_clarification", language); + String confirmRequest = optionalString(payload, "target_confirm_request", language); + if (hasText(confirmRequest) + && (hasItems(intentUnderstanding) + || hasItems(alignmentAndClarification) + || hasItems(requestForClarification))) { + throw invalidInput( + language, + "Target confirm request extracted together with the intent understanding, alignment and" + + " clarification or clarification request sections; a confirm-request round carries only" + + " the summary and the confirm request."); + } + return new TargetProposeContent( + description, intentUnderstanding, alignmentAndClarification, requestForClarification, confirmRequest); + } + + private static NegotiationContent mapFeasibilityProposeContent(Map payload, String language) { + String description = requiredString(payload, "feasibility_negotiation_description", language); + NegotiationAction action = feasibilityAction(payload, language); + List contentsToEvaluate = optionalItems(payload, "contents_to_evaluate", language); + List infeasibilityDetails = + optionalItems(payload, "infeasibility_details_and_proposal", language); + String confirmRequest = optionalString(payload, "feasibility_confirm_request", language); + if (hasText(confirmRequest)) { + if (action != NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + throw invalidInput( + language, + "Feasibility confirm request requires the REQUEST_FEASIBILITY_EVALUATION action but the" + + " extracted action was " + + action.name() + + "."); + } + if (hasItems(contentsToEvaluate) || hasItems(infeasibilityDetails)) { + throw invalidInput( + language, + "Feasibility confirm request extracted together with the contents to evaluate or" + + " infeasibility details and proposal sections; a confirm-request round carries only" + + " the summary and the confirm request."); + } + } else if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + if (contentsToEvaluate == null || contentsToEvaluate.isEmpty()) { + throw invalidInput( + language, + "Feasibility evaluation request extracted no contents to evaluate; the driven section would" + + " be empty."); + } + } else if (infeasibilityDetails == null || infeasibilityDetails.isEmpty()) { + throw invalidInput( + language, + "Alternative proposal on failure extracted no infeasibility details; the driven section would be" + + " empty."); + } + return new FeasibilityProposeContent( + description, action, contentsToEvaluate, infeasibilityDetails, confirmRequest); + } + + private static NegotiationContent mapEndingContent( + Map payload, NegotiationType type, NegotiationPerformative performative, String language) { + NegotiationConclusion conclusion = requiredConclusion(payload, language); + requireConclusionMatchesPhase(conclusion, performative, language); + return switch (type) { + case INFORMATION -> new InformationEndingContent( + conclusion, + requiredNonEmptyItems(payload, "items", "information negotiation result content", language)); + case TARGET -> mapTargetEndingContent(payload, conclusion, language); + case FEASIBILITY -> new FeasibilityEndingContent( + conclusion, requiredString(payload, "feasibility_summary", language)); + }; + } + + private static NegotiationContent mapTargetEndingContent( + Map payload, NegotiationConclusion conclusion, String language) { + String confirmedIntent = optionalString(payload, "confirmed_intent", language); + String failureReason = optionalString(payload, "failure_reason", language); + if (conclusion == NegotiationConclusion.ACCEPT && (confirmedIntent == null || confirmedIntent.isBlank())) { + throw slotMissing(language, "confirmed_intent"); + } + if (conclusion == NegotiationConclusion.REJECT && (failureReason == null || failureReason.isBlank())) { + throw slotMissing(language, "failure_reason"); + } + return new TargetEndingContent(conclusion, confirmedIntent, failureReason); + } + + private static void requireConclusionMatchesPhase( + NegotiationConclusion conclusion, NegotiationPerformative performative, String language) { + NegotiationConclusion expected = performative == NegotiationPerformative.ACCEPT + ? NegotiationConclusion.ACCEPT + : NegotiationConclusion.REJECT; + if (conclusion != expected) { + throw new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH, + language, + Map.of("expected", expected.literal(), "actual", conclusion.literal())); + } + } + + private static NegotiationAction feasibilityAction(Map payload, String language) { + Object value = payload.get("action"); + if (value == null) { + throw invalidInput( + language, + "Feasibility negotiation content extraction produced no action; the action drives the conditional" + + " sections of the message."); + } + if (value instanceof String action) { + for (NegotiationAction candidate : NegotiationAction.values()) { + if (candidate.name().equals(action)) { + return candidate; + } + } + } + throw extractFailed(language, "action", "Feasibility negotiation action must be one of the two action names"); + } + + private static NegotiationConclusion requiredConclusion(Map payload, String language) { + Object value = payload.get("conclusion"); + if (value == null) { + throw slotMissing(language, "conclusion"); + } + if (value instanceof String conclusion) { + for (NegotiationConclusion candidate : NegotiationConclusion.values()) { + if (candidate.literal().equals(conclusion)) { + return candidate; + } + } + } + throw extractFailed(language, "conclusion", "Negotiation conclusion must be Accept or Reject"); + } + + private static String requiredString(Map payload, String field, String language) { + Object value = payload.get(field); + if (value == null || (value instanceof String text && text.isBlank())) { + throw slotMissing(language, field); + } + if (value instanceof String text) { + return text; + } + throw extractFailed(language, field, "Field " + field + " must be a string"); + } + + private static String optionalString(Map payload, String field, String language) { + Object value = payload.get(field); + if (value == null) { + return null; + } + if (value instanceof String text) { + return text; + } + throw extractFailed(language, field, "Field " + field + " must be a string or null"); + } + + private static boolean hasText(@Nullable String value) { + return value != null && !value.isBlank(); + } + + private static boolean hasItems(@Nullable List items) { + return items != null && !items.isEmpty(); + } + + private static List requiredItems(Map payload, String field, String language) { + Object value = payload.get(field); + if (value == null) { + throw slotMissing(language, field); + } + return itemsOf(value, field, language); + } + + private static List requiredNonEmptyItems( + Map payload, String field, String description, String language) { + List items = requiredItems(payload, field, language); + if (items.isEmpty()) { + throw slotMissing(language, description); + } + return items; + } + + private static List optionalItems(Map payload, String field, String language) { + Object value = payload.get(field); + if (value == null) { + return null; + } + return itemsOf(value, field, language); + } + + private static List itemsOf(Object value, String field, String language) { + if (!(value instanceof List entries)) { + throw extractFailed(language, field, "Field " + field + " must be an array of items"); + } + List items = new ArrayList<>(); + for (Object entry : entries) { + if (!(entry instanceof Map entryMap)) { + throw extractFailed(language, field, "Field " + field + " must contain item objects"); + } + Object name = entryMap.get("name"); + if (!(name instanceof String itemName) || itemName.isBlank()) { + throw extractFailed(language, field, "Field " + field + " contained an item without a name"); + } + Object itemValue = entryMap.get("value"); + if (itemValue != null && !(itemValue instanceof String)) { + throw extractFailed( + language, field, "Field " + field + " contained an item whose value is not a string"); + } + items.add(new NegotiationItem(itemName, (String) itemValue)); + } + return List.copyOf(items); + } + + private static String phaseToken(NegotiationPerformative performative) { + return performative.name().toLowerCase(Locale.ROOT); + } + + private static String promptCategory(@Nullable NegotiationType type) { + if (type == null) { + return CATEGORY_ABORT; + } + return switch (type) { + case INFORMATION -> CATEGORY_INFORMATION; + case TARGET -> CATEGORY_TARGET; + case FEASIBILITY -> CATEGORY_FEASIBILITY; + }; + } + + private static NegotiationGenerationException slotMissing(String language, String field) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_FIELD_MISSING, language, Map.of("field", field)); + } + + private static NegotiationGenerationException extractFailed(String language, String field, String reason) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED, language, Map.of("field", field, "reason", reason)); + } + + private static NegotiationGenerationException invalidInput(String language, String reason) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, Map.of("reason", reason)); + } + + private static NegotiationGenerationException responseInvalid(String language, String detail) { + LOGGER.atWarn().log("negotiation_content_extraction_response_invalid detail={}", detail); + return new NegotiationGenerationException( + ErrorCatalog.LLM_RESPONSE_INVALID, language, Map.of("step", extractionStepLabel(language))); + } + + private static String extractionStepLabel(String language) { + return language != null && language.startsWith("zh") ? EXTRACTION_STEP_ZH : EXTRACTION_STEP_EN; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java new file mode 100644 index 00000000..78126942 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityEndingGenerator.java @@ -0,0 +1,48 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.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 new file mode 100644 index 00000000..60d3f113 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/FeasibilityProposeGenerator.java @@ -0,0 +1,115 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import org.jspecify.annotations.Nullable; + +/** + * Generator for feasibility negotiation propose messages. + * + *

The message category selects which conditional section is rendered: requesting a feasibility evaluation renders + * the contents to evaluate, proposing an alternative after an infeasible outcome renders the infeasibility details and + * proposal, and a non-blank confirm request marks the derived "assessed as feasible and requesting confirmation" + * category (action {@code REQUEST_FEASIBILITY_EVALUATION} with both lists empty) that renders only the confirm request. + * Exactly one of the three sections is always present. + * + * @since 2026-08 + */ +final class FeasibilityProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a feasibility negotiation propose message. + * + * @param context negotiation context of the message + * @param content feasibility propose content + * @param template feasibility propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered feasibility propose message text + * @throws NullPointerException if the action is null + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the summary is blank + * or the action-selected section is empty, or {@code negotiation.invalid_input} if the confirm request is + * combined with the wrong action or either conditional section + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + FeasibilityProposeContent proposeContent = + contentOf(content, FeasibilityProposeContent.class, "Feasibility propose generator"); + requiredText( + proposeContent.feasibilityNegotiationDescription(), + "content.feasibilityNegotiationDescription", + "Feasibility negotiation description", + vocabulary); + NegotiationAction action = proposeContent.action(); + Objects.requireNonNull( + action, + "Feasibility negotiation action must not be null; it selects the conditional sections of the message."); + String confirmRequest = presentText(proposeContent.feasibilityConfirmRequest()); + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.feasibility"), proposeContent.feasibilityNegotiationDescription()); + if (confirmRequest != null) { + if (action != NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + throw invalidInput( + "Feasibility confirm request requires the REQUEST_FEASIBILITY_EVALUATION action but the content" + + " carries " + + action.name() + + ".", + vocabulary); + } + if (hasItems(proposeContent.contentsToEvaluate()) + || hasItems(proposeContent.infeasibilityDetailsAndProposal())) { + throw invalidInput( + "Feasibility confirm request must not be combined with the contents to evaluate or" + + " infeasibility details and proposal sections; a confirm-request round carries only" + + " the summary and the confirm request.", + vocabulary); + } + slots.put(vocabulary.get("slot.feasibility_confirm_request"), confirmRequest); + } else if (action == NegotiationAction.REQUEST_FEASIBILITY_EVALUATION) { + slots.put( + vocabulary.get("slot.feasibility_evaluate"), + formatItems( + requiredItems( + proposeContent.contentsToEvaluate(), + "content.contentsToEvaluate", + "Contents to evaluate of a feasibility evaluation request", + vocabulary), + vocabulary)); + } else { + slots.put( + vocabulary.get("slot.feasibility_infeasible"), + formatItems( + requiredItems( + proposeContent.infeasibilityDetailsAndProposal(), + "content.infeasibilityDetailsAndProposal", + "Infeasibility details and proposal of an alternative proposal", + vocabulary), + vocabulary)); + } + return render(template, slots); + } + + private static @Nullable String presentText(@Nullable String value) { + return value == null || value.isBlank() ? null : value; + } + + private static boolean hasItems(@Nullable List items) { + return items != null && !items.isEmpty(); + } + + private static NegotiationGenerationException invalidInput(String reason, Vocabulary vocabulary) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT, vocabulary.language(), Map.of("reason", reason)); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.java new file mode 100644 index 00000000..d9d4f974 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationEndingGenerator.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.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 new file mode 100644 index 00000000..abc84ccf --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/InformationProposeGenerator.java @@ -0,0 +1,50 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; + +/** + * Generator for information negotiation propose messages. + * + *

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

Implementations decide how the text is understood; the returned content must already satisfy the generator input + * rules so the deterministic rendering step can consume it directly. + * + * @since 2026-08 + */ +public interface NegotiationContentExtractor { + + /** + * Extracts the typed content of one negotiation message from free text. + * + * @param text free-text input describing the message content + * @param reference reference identifying the negotiation type, phase and language to extract for + * @return typed negotiation content matching the reference + * @throws NegotiationGenerationException with code {@code llm.not_configured} when no LLM client is configured, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the LLM invocation fails, + * {@code negotiation.content_extract_failed} when the response cannot be parsed as the expected content, + * {@code negotiation.field_missing} when a required field is missing, {@code negotiation.conclusion_mismatch} + * when the extracted conclusion contradicts the addressed phase, or {@code negotiation.invalid_input} when the + * extracted content contradicts the addressed phase or action + */ + NegotiationContent extract(String text, NegotiationReference reference); +} 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 new file mode 100644 index 00000000..d9114957 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentService.java @@ -0,0 +1,322 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Shared service for the negotiation content layer consumed by both the client and the server facade. + * + *

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

The wiring is shared by the client and the server builder: the message language comes from the prompt runtime + * config (negotiation templates and vocabularies are classpath-fixed), the retry attempt limit comes from the LLM + * config, and the LLM client is passed by the caller and may be null when the provider is {@code local_rule}. + * + * @param config unified SDK config + * @param llmClient LLM client for the LLM-backed steps; null keeps those steps unavailable + * @return assembled negotiation generation orchestrator + */ + public static NegotiationGenerationOrchestrator buildOrchestrator( + @NonNull A2ATConfig config, @Nullable LLMClient llmClient) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(config.prompt().language()) + .llmClient(llmClient) + .maxAttempts(config.llm().maxAttempts()) + .maxTextChars(config.inputLimits().maxTextChars()) + .build(); + } + + /** + * Generates a propose negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri template URI whose performative segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content + * type + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when a required content + * field is blank or empty, or {@code negotiation.invalid_input} when the content combines a non-blank confirm + * request with conditional sections (target: the three clarification lists; feasibility: either conditional + * list or the alternative action) + * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, + * or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateProposeFromData( + @NonNull NegotiationProposeData data, @NonNull TemplateUri templateUri) { + return orchestrator.generateProposeFromData(data, templateUri); + } + + /** + * Generates an accept negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed terminal input whose content conclusion must be {@code Accept} + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Accept}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, + * or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateAcceptFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + return orchestrator.generateAcceptFromData(data, templateUri); + } + + /** + * Generates a reject negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed terminal input whose content conclusion must be {@code Reject} + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI's performative or type contradicts the method or the content + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} when the content + * conclusion is not {@code Reject}, or {@code negotiation.content_invalid} when a required content field is + * blank or empty + * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, + * or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateRejectFromData( + @NonNull NegotiationEndingData data, @NonNull TemplateUri templateUri) { + return orchestrator.generateRejectFromData(data, templateUri); + } + + /** + * Generates an abort negotiation message from typed data, deterministically without any LLM call. + * + * @param data typed abort input carrying the termination reason + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} when the termination + * reason is blank + * @throws NegotiationGenerationException with the code {@code template.not_found} when loading the template fails, + * or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateAbortFromData(@NonNull NegotiationAbortData data, @NonNull TemplateUri templateUri) { + return orchestrator.generateAbortFromData(data, templateUri); + } + + /** + * Generates a propose negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI whose performative segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, + * {@code negotiation.field_missing} when the extracted content misses a + * required field, or {@code negotiation.invalid_input} when the text is blank, the extracted content + * contradicts the performative or the extracted confirm request is combined with conditional sections or the + * wrong feasibility action, or {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateProposeFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return orchestrator.generateProposeFromText(text, context, templateUri); + } + + /** + * Generates an accept negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, + * {@code negotiation.field_missing} when the extracted content misses a + * required field, or {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Accept}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateAcceptFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return orchestrator.generateAcceptFromText(text, context, templateUri); + } + + /** + * Generates a reject negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, + * {@code negotiation.field_missing} when the extracted content misses a + * required field, or {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Reject}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateRejectFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return orchestrator.generateRejectFromText(text, context, templateUri); + } + + /** + * Generates an abort negotiation message from free text through one LLM content-extraction step. + * + * @param text free-text input stating the termination reason + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code template.not_found}, + * {@code negotiation.content_extract_failed} or {@code llm.invocation_failed} or {@code llm.response_invalid} + * when loading or extracting fails, {@code template.render_failed} when rendering the template fails, + * {@code negotiation.field_missing} when the extracted content misses the + * termination reason, or {@code negotiation.invalid_input} when the text is blank, or + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateAbortFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return orchestrator.generateAbortFromText(text, context, templateUri); + } + + /** + * Validates a propose negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI whose performative segment must be {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, + * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or + * {@code template.not_found} when the validation pipeline fails + */ + public FilledParamData validateProposePromptAndDataFilling( + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateProposePromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates an accept negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, + * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or + * {@code template.not_found} when the validation pipeline fails + */ + public FilledParamData validateAcceptPromptAndDataFilling( + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateAcceptPromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates a reject negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI whose performative segment must be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, + * {@code llm.invocation_failed} or {@code llm.response_invalid}, {@code input.text_too_long} or + * {@code template.not_found} when the validation pipeline fails + */ + public FilledParamData validateRejectPromptAndDataFilling( + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateRejectPromptAndDataFilling(prompt, context, schema, templateUri); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationParamExtractionException with the code {@code input.text_too_long} when the prompt exceeds the + * configured maximum length, or another validation pipeline failure code otherwise + */ + public FilledParamData validateAbortPromptAndDataFilling( + String prompt, + @Nullable NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return orchestrator.validateAbortPromptAndDataFilling(prompt, context, schema, templateUri); + } +} 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 new file mode 100644 index 00000000..0def625f --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestrator.java @@ -0,0 +1,651 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.Vocabulary; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.ParamExtractor; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Orchestrates the negotiation content layer: deterministic message generation, single-step LLM content extraction and + * the validation-plus-parameter-extraction pipeline. + * + *

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

Instances are created through {@link NegotiationGenerationOrchestratorBuilder}; the builder wires the default + * collaborators and allows overriding each of them. + * + * @since 2026-08 + */ +public final class NegotiationGenerationOrchestrator { + + private static final Logger DEFAULT_LOGGER = LoggerFactory.getLogger(NegotiationGenerationOrchestrator.class); + + private static final String STEP_CONTENT_EXTRACT = "negotiation_content_extract"; + + private final String language; + + private final int maxAttempts; + + private final int maxTextChars; + + private final NegotiationTemplateLoader templateLoader; + + private final NegotiationContentExtractor contentExtractor; + + private final ParamExtractor paramExtractor; + + private final NegotiationGeneratorRegistry generatorRegistry; + + private final Vocabulary vocabulary; + + private final Logger logger; + + NegotiationGenerationOrchestrator( + String language, + int maxAttempts, + int maxTextChars, + NegotiationTemplateLoader templateLoader, + NegotiationContentExtractor contentExtractor, + ParamExtractor paramExtractor, + NegotiationGeneratorRegistry generatorRegistry, + Vocabulary vocabulary, + Logger logger) { + this.language = language; + this.maxAttempts = maxAttempts; + this.maxTextChars = maxTextChars; + this.templateLoader = templateLoader; + this.contentExtractor = contentExtractor; + this.paramExtractor = paramExtractor; + this.generatorRegistry = generatorRegistry; + this.vocabulary = vocabulary; + this.logger = logger == null ? DEFAULT_LOGGER : logger; + } + + /** + * Generates a propose-phase negotiation message from typed data. + * + *

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

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

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

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

This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/target-negotiation/propose/v1}; its phase segment + * must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting + * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or + * {@code negotiation.invalid_input} when the text is blank or the extracted content contradicts the phase, + * {@code template.render_failed} when rendering the template fails, or + * {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateProposeFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPerformative.PROPOSE); + } + + /** + * Generates an accept-phase negotiation message from free text. + * + *

This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Accept}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting + * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or + * {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} when the + * extracted conclusion is not {@code Accept}, {@code template.render_failed} when rendering the template + * fails, or {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateAcceptFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPerformative.ACCEPT); + } + + /** + * Generates a reject-phase negotiation message from free text. + * + *

This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Reject}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; its phase + * segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting + * its retries, {@code negotiation.field_missing} when the extracted content misses a required field, or + * {@code negotiation.invalid_input} when the text is blank, {@code negotiation.conclusion_mismatch} when the + * extracted conclusion is not {@code Reject}, {@code template.render_failed} when rendering the template + * fails, or {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateRejectFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPerformative.REJECT); + } + + /** + * Generates an abort negotiation message from free text. + * + *

This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} and {@code llm.response_invalid}. + * + * @param text free-text input stating the termination reason + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI; + * the emitted negotiation context is the input context stamped with the performative of the addressed template, + * so it may differ from the context the caller passed in + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationGenerationException with the code {@code template.not_found} when no template or prompt + * resource exists for the URI and language, {@code negotiation.content_extract_failed} or + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the extraction step fails after exhausting + * its retries, {@code negotiation.field_missing} when the extracted content misses the termination reason, or + * {@code negotiation.invalid_input} when the text is blank, {@code template.render_failed} when rendering the + * template fails, or {@code input.text_too_long} when the text exceeds the configured maximum length + */ + public MetadataContent generateAbortFromText( + String text, @NonNull NegotiationContext context, @NonNull TemplateUri templateUri) { + return generateFromText(text, context, templateUri, NegotiationPerformative.ABORT); + } + + /** + * Validates a propose-phase negotiation message and extracts its parameters. + * + *

The pipeline checks the template URI before any LLM call, runs the deterministic rule gate, then performs one + * semantic validation LLM call (retried on the retryable failure codes) and merges the extracted parameters with + * the rule-level context parameters; context parameters win on conflict. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI declaring the expected negotiation type and phase; its phase segment must be + * {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI's phase contradicts the method + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length + */ + public FilledParamData validateProposePromptAndDataFilling( + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.PROPOSE); + } + + /** + * Validates an accept-phase negotiation message and extracts its parameters. + * + *

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

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

The pipeline is the one of {@link #validateProposePromptAndDataFilling(String, NegotiationContext, Map, + * TemplateUri)} with the expected phase fixed to abort: the template URI must address the common abort template and + * the message must satisfy the abort-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri template URI of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI does not address the common abort template + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input} when the prompt is + * null or blank, or when the context is null and the message is therefore not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length + */ + public FilledParamData validateAbortPromptAndDataFilling( + String prompt, + NegotiationContext context, + @NonNull Map schema, + @NonNull TemplateUri templateUri) { + return validatePromptAndDataFilling(prompt, context, schema, templateUri, NegotiationPerformative.ABORT); + } + + private MetadataContent generateFromData( + NegotiationContext context, + NegotiationContent content, + TemplateUri templateUri, + NegotiationPerformative performative) { + requireContext(context); + try { + NegotiationReference reference = requireReference(templateUri, performative); + PromptTemplate template = loadTemplate(reference); + String promptText = renderMessage(context, content, reference, template); + return completeGeneration(reference, promptText, context); + } catch (NegotiationGenerationException failure) { + logger.atWarn() + .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); + throw failure; + } + } + + private MetadataContent generateFromText( + String text, NegotiationContext context, TemplateUri templateUri, NegotiationPerformative performative) { + requireContext(context); + requireTextWithinLimit(text); + try { + NegotiationReference reference = requireReference(templateUri, performative); + PromptTemplate template = loadTemplate(reference); + NegotiationContent content = extractContent(text, reference); + String promptText = renderMessage(context, content, reference, template); + return completeGeneration(reference, promptText, context); + } catch (NegotiationGenerationException failure) { + logger.atWarn() + .log("negotiation_generation_failed code={} template_uri={}", failure.getCode(), templateUri.uri()); + throw failure; + } + } + + private PromptTemplate loadTemplate(NegotiationReference reference) { + try { + return templateLoader.load(reference); + } catch (ResourceNotFoundException exception) { + throw templateNotFound(reference, exception); + } + } + + private NegotiationContent extractContent(String text, NegotiationReference reference) { + try { + return withRetry(STEP_CONTENT_EXTRACT, () -> contentExtractor.extract(text, reference)); + } catch (ResourceNotFoundException exception) { + throw templateNotFound(reference, exception); + } + } + + private static NegotiationGenerationException templateNotFound( + NegotiationReference reference, ResourceNotFoundException cause) { + return new NegotiationGenerationException( + ErrorCatalog.TEMPLATE_NOT_FOUND, + reference.language(), + Map.of("template_uri", reference.uri(), "language", reference.language()), + cause); + } + + private String renderMessage( + NegotiationContext context, + NegotiationContent content, + NegotiationReference reference, + PromptTemplate template) { + NegotiationGenerator generator = + generatorRegistry.resolve(reference.type(), reference.performative(), content, reference.language()); + try { + return generator.generate(context, content, template, vocabulary); + } catch (NegotiationRenderException exception) { + throw new NegotiationGenerationException( + ErrorCatalog.TEMPLATE_RENDER_FAILED, + reference.language(), + Map.of( + "template_uri", reference.uri(), + "reason", String.valueOf(exception.getMessage())), + exception); + } + } + + private MetadataContent completeGeneration( + NegotiationReference reference, String promptText, NegotiationContext context) { + // The operation is the source of truth for the performative: the emitted context is the input context stamped + // with the performative of the addressed template, overriding whatever the caller passed in. + NegotiationContext stampedContext = context.withPerformative(reference.performative()); + logger.atInfo().log( + "negotiation_generation_completed uri={} type={} performative={} round={} id={}", + reference.uri(), + reference.type(), + reference.performative(), + stampedContext.round(), + stampedContext.id()); + return new MetadataContent( + reference.uri(), promptText, ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, stampedContext); + } + + private FilledParamData validatePromptAndDataFilling( + String prompt, + NegotiationContext context, + Map schema, + TemplateUri templateUri, + NegotiationPerformative performative) { + Objects.requireNonNull(schema, "Parameter schema must not be null."); + requirePromptWithinLimit(prompt); + NegotiationReference reference = requireReference(templateUri, performative); + try { + return paramExtractor.extract(prompt, context, schema, reference); + } catch (NegotiationParamExtractionException failure) { + logger.atWarn() + .log( + "negotiation_param_extraction_failed code={} error_count={}", + failure.getCode(), + failure.getErrors().size()); + throw failure; + } + } + + /** + * Derives the reference of a typed template URI against one expected performative. + * + * @param templateUri template URI declared by the caller + * @param performative performative the calling method operates on + * @return reference addressed by the URI carrying the expected performative + * @throws IllegalArgumentException if the URI does not address a negotiation template of the expected performative + */ + private NegotiationReference requireReference(TemplateUri templateUri, NegotiationPerformative performative) { + return NegotiationReference.fromTemplateUri(templateUri, performative, language) + .orElseThrow(() -> new IllegalArgumentException( + "Template URI does not address a negotiation template of the expected performative " + + performative + " (" + NegotiationReference.uriSegmentOf(performative) + "): " + + templateUri.uri() + ".")); + } + + /** + * Runs one LLM step with the retry policy of the negotiation content layer. + * + *

A failure carrying a retryable code is re-run up to the configured attempt limit; a failure carrying any other + * code is rethrown immediately. When the attempts are exhausted, the original failure is rethrown with its original + * error code. + * + * @param result type of the step + * @param step internal diagnostic step name used in the retry logs + * @param action step implementation performing exactly one LLM call + * @return step result + */ + private T withRetry(String step, Supplier action) { + RuntimeException lastFailure = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return action.get(); + } catch (RuntimeException failure) { + String code = retryableCode(failure); + if (code == null) { + throw failure; + } + lastFailure = failure; + if (attempt == maxAttempts) { + logger.atWarn() + .log( + "negotiation_llm_retry_exhausted step={} max_attempts={} code={}", + step, + maxAttempts, + code); + throw failure; + } + logger.atWarn() + .log( + "negotiation_llm_retry step={} attempt={} max_attempts={} code={}", + step, + attempt, + maxAttempts, + code); + } + } + throw lastFailure; + } + + private static String retryableCode(RuntimeException failure) { + String code = null; + if (failure instanceof NegotiationGenerationException generation) { + code = generation.getCode(); + } else if (failure instanceof NegotiationParamExtractionException extraction) { + code = extraction.getCode(); + } + if (code == null) { + return null; + } + boolean retryable = + ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode().equals(code) + || ErrorCatalog.LLM_INVOCATION_FAILED.getCode().equals(code) + || ErrorCatalog.LLM_RESPONSE_INVALID.getCode().equals(code); + return retryable ? code : null; + } + + private static void requireContext(NegotiationContext context) { + Objects.requireNonNull(context, "Negotiation context must not be null."); + } + + private void requireTextWithinLimit(String text) { + if (InputLimitConfig.isTooLong(text, maxTextChars)) { + throw new NegotiationGenerationException( + ErrorCatalog.INPUT_TEXT_TOO_LONG, language, inputTooLongFacts(text)); + } + } + + private void requirePromptWithinLimit(String prompt) { + if (InputLimitConfig.isTooLong(prompt, maxTextChars)) { + throw new NegotiationParamExtractionException( + ErrorCatalog.INPUT_TEXT_TOO_LONG, language, inputTooLongFacts(prompt), List.of()); + } + } + + private Map inputTooLongFacts(String text) { + return Map.of( + "actual_length", String.valueOf(text == null ? 0 : text.length()), + "max_chars", String.valueOf(maxTextChars)); + } +} 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 new file mode 100644 index 00000000..2c8ab435 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilder.java @@ -0,0 +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); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java new file mode 100644 index 00000000..ca3e6a8b --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerator.java @@ -0,0 +1,31 @@ +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/NegotiationGeneratorRegistry.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java new file mode 100644 index 00000000..17d45bca --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistry.java @@ -0,0 +1,152 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Dispatch table from negotiation type and phase to the generator serving them. + * + *

Dispatch requires an exact runtime type match between the content and the addressed (type, phase) pair: propose + * phases only accept propose content, terminal phases only accept ending content whose conclusion matches the phase, + * and the content type must match the negotiation type. The abort phase dispatches to the type-independent abort + * generator and requires abort content. Subtype matching is deliberately not supported; new content types must be + * registered explicitly. + * + * @since 2026-08 + */ +final class NegotiationGeneratorRegistry { + + private static final Logger LOGGER = LoggerFactory.getLogger(NegotiationGeneratorRegistry.class); + + private final Map proposeGenerators = new EnumMap<>(NegotiationType.class); + + private final Map endingGenerators = new EnumMap<>(NegotiationType.class); + + private final NegotiationGenerator abortGenerator = new AbortGenerator(); + + /** Creates a registry holding the built-in negotiation generators. */ + public NegotiationGeneratorRegistry() { + proposeGenerators.put(NegotiationType.INFORMATION, new InformationProposeGenerator()); + proposeGenerators.put(NegotiationType.TARGET, new TargetProposeGenerator()); + proposeGenerators.put(NegotiationType.FEASIBILITY, new FeasibilityProposeGenerator()); + endingGenerators.put(NegotiationType.INFORMATION, new InformationEndingGenerator()); + endingGenerators.put(NegotiationType.TARGET, new TargetEndingGenerator()); + endingGenerators.put(NegotiationType.FEASIBILITY, new FeasibilityEndingGenerator()); + } + + /** + * Resolves the generator for one (type, phase, content) triple. + * + * @param type negotiation type addressed by the template URI; null only for the type-independent abort phase + * @param phase API-level phase addressed by the calling method + * @param content typed content of the message + * @param language message language used to render the failure message of a conclusion mismatch + * @return generator registered for the exact (type, phase) pair + * @throws NullPointerException if the phase or content is null, or the type is null on a typed phase + * @throws IllegalArgumentException if the content family does not match the phase or the content runtime type does + * not match the negotiation type + * @throws NegotiationGenerationException with the code {@code negotiation.conclusion_mismatch} if an ending content + * carries a conclusion that does not match the phase + */ + public NegotiationGenerator resolve( + @Nullable NegotiationType type, + NegotiationPerformative phase, + NegotiationContent content, + String language) { + Objects.requireNonNull(phase, "Negotiation phase must not be null."); + Objects.requireNonNull(content, "Negotiation content must not be null."); + if (phase == NegotiationPerformative.ABORT) { + if (type != null) { + throw new IllegalArgumentException( + "The ABORT phase is type-independent and must not carry a type but carried " + type + "."); + } + if (content.getClass() != NegotiationAbortContent.class) { + throw new IllegalArgumentException("The ABORT phase requires abort content but received " + + content.getClass().getSimpleName() + "."); + } + LOGGER.atDebug().log( + "negotiation_generator_dispatched generator=AbortGenerator type=common performative=ABORT"); + return abortGenerator; + } + Objects.requireNonNull(type, "Negotiation type must not be null for the " + phase + " phase."); + boolean proposePhase = phase == NegotiationPerformative.PROPOSE; + boolean proposeContent = content instanceof NegotiationProposeContent; + if (proposePhase != proposeContent) { + throw new IllegalArgumentException( + "The " + phase + " phase requires " + (proposePhase ? "propose" : "ending") + + " content but received " + + (proposeContent ? "propose" : "ending") + " content of type " + + content.getClass().getSimpleName() + "."); + } + Class expectedType = expectedContentClass(type, proposePhase); + if (content.getClass() != expectedType) { + throw new IllegalArgumentException( + "Negotiation type " + type + " requires content of type " + expectedType.getSimpleName() + + " but received " + content.getClass().getSimpleName() + "."); + } + if (!proposePhase) { + requireConclusionMatchesPhase((NegotiationEndingContent) content, phase, language); + } + NegotiationGenerator generator = (proposePhase ? proposeGenerators : endingGenerators).get(type); + if (generator == null) { + throw new IllegalArgumentException( + "No negotiation generator is registered for type " + type + " and phase " + phase + "."); + } + LOGGER.atDebug().log( + "negotiation_generator_dispatched generator={} type={} performative={}", + generator.getClass().getSimpleName(), + type, + phase); + return generator; + } + + private static void requireConclusionMatchesPhase( + NegotiationEndingContent content, NegotiationPerformative phase, String language) { + NegotiationConclusion conclusion = Objects.requireNonNull( + content.conclusion(), + "Negotiation conclusion must not be null; the " + phase + " phase requires a conclusion."); + NegotiationConclusion expected = + phase == NegotiationPerformative.ACCEPT ? NegotiationConclusion.ACCEPT : NegotiationConclusion.REJECT; + if (conclusion != expected) { + throw new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH, + language, + Map.of("expected", expected.literal(), "actual", conclusion.literal())); + } + } + + private static Class expectedContentClass( + NegotiationType type, boolean proposePhase) { + if (proposePhase) { + return switch (type) { + case INFORMATION -> InformationProposeContent.class; + case TARGET -> TargetProposeContent.class; + case FEASIBILITY -> FeasibilityProposeContent.class; + }; + } + return switch (type) { + case INFORMATION -> InformationEndingContent.class; + case TARGET -> TargetEndingContent.class; + case FEASIBILITY -> FeasibilityEndingContent.class; + }; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java new file mode 100644 index 00000000..2417ed41 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatter.java @@ -0,0 +1,41 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; + +/** + * Formats negotiation item lists as numbered markdown lines. + * + *

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

Extraction schemas are keyed by the snake_case property names of the extracted content, one schema per + * (negotiation type, phase) pair with accept and reject sharing the terminal schema. The semantic validation schema + * merges a caller-provided parameter schema into the fixed four-key output contract of the semantic validation step. + * + * @since 2026-08 + */ +final class NegotiationJsonSchemaBuilder { + + private static final List CONCLUSION_ENUM = List.of("Accept", "Reject"); + + private static final List ACTION_ENUM = + List.of("REQUEST_FEASIBILITY_EVALUATION", "PROPOSE_ALTERNATIVE_ON_FAILURE"); + + /** + * Builds the content extraction schema of one (negotiation type, phase) pair. + * + * @param type negotiation type whose content is extracted; null only for the type-independent abort phase + * @param phase API-level phase of the extraction; accept and reject share the terminal schema + * @return JSON Schema describing the snake_case extraction output of the pair + * @throws NullPointerException if the phase is null, or the type is null on a typed phase + */ + public Map buildExtractionSchema( + @Nullable NegotiationType type, NegotiationPerformative phase) { + Objects.requireNonNull(phase, "Negotiation phase must not be null."); + if (phase == NegotiationPerformative.ABORT) { + if (type != null) { + throw new IllegalArgumentException( + "The ABORT phase is type-independent and must not carry a type but carried " + type + "."); + } + return abortSchema(); + } + Objects.requireNonNull(type, "Negotiation type must not be null for the " + phase + " phase."); + return switch (type) { + case INFORMATION -> phase == NegotiationPerformative.PROPOSE + ? informationProposeSchema() + : informationEndingSchema(); + case TARGET -> phase == NegotiationPerformative.PROPOSE ? targetProposeSchema() : targetEndingSchema(); + case FEASIBILITY -> phase == NegotiationPerformative.PROPOSE + ? feasibilityProposeSchema() + : feasibilityEndingSchema(); + }; + } + + private static Map abortSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("termination_reason", Map.of("type", "string")); + return objectSchema(properties, List.of("termination_reason")); + } + + private static Map informationProposeSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("items", nonEmptyItemArraySchema()); + properties.put("relationship", nullableStringSchema()); + return objectSchema(properties, List.of("items")); + } + + private static Map informationEndingSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("conclusion", conclusionSchema()); + properties.put("items", nonEmptyItemArraySchema()); + return objectSchema(properties, List.of("conclusion", "items")); + } + + private static Map targetProposeSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("target_negotiation_description", Map.of("type", "string")); + properties.put("intent_understanding", nullableItemArraySchema()); + properties.put("alignment_and_clarification", nullableItemArraySchema()); + properties.put("request_for_clarification", nullableItemArraySchema()); + properties.put("target_confirm_request", nullableStringSchema()); + return objectSchema(properties, List.of("target_negotiation_description")); + } + + private static Map targetEndingSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("conclusion", conclusionSchema()); + properties.put("confirmed_intent", nullableStringSchema()); + properties.put("failure_reason", nullableStringSchema()); + return objectSchema(properties, List.of("conclusion")); + } + + private static Map feasibilityProposeSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("feasibility_negotiation_description", Map.of("type", "string")); + Map action = new LinkedHashMap<>(); + action.put("type", "string"); + action.put("enum", ACTION_ENUM); + properties.put("action", action); + properties.put("contents_to_evaluate", nullableItemArraySchema()); + properties.put("infeasibility_details_and_proposal", nullableItemArraySchema()); + properties.put("feasibility_confirm_request", nullableStringSchema()); + return objectSchema(properties, List.of("feasibility_negotiation_description", "action")); + } + + private static Map feasibilityEndingSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("conclusion", conclusionSchema()); + properties.put("feasibility_summary", Map.of("type", "string")); + return objectSchema(properties, List.of("conclusion", "feasibility_summary")); + } + + private static Map itemArraySchema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", "array"); + schema.put("items", itemSchema()); + return schema; + } + + private static Map nonEmptyItemArraySchema() { + Map schema = new LinkedHashMap<>(itemArraySchema()); + schema.put("minItems", 1); + return schema; + } + + private static Map nullableItemArraySchema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", List.of("array", "null")); + schema.put("items", itemSchema()); + return schema; + } + + private static Map itemSchema() { + Map itemProperties = new LinkedHashMap<>(); + itemProperties.put("name", Map.of("type", "string")); + itemProperties.put("value", nullableStringSchema()); + return objectSchema(itemProperties, List.of("name", "value")); + } + + private static Map conclusionSchema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", "string"); + schema.put("enum", CONCLUSION_ENUM); + return schema; + } + + private static Map nullableStringSchema() { + return Map.of("type", List.of("string", "null")); + } + + private static Map objectSchema(Map properties, List required) { + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + schema.put("required", required); + return schema; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java new file mode 100644 index 00000000..aac9f813 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationMessageBuilder.java @@ -0,0 +1,78 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Assembles the LLM message list of one negotiation LLM step. + * + *

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

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

The rendering itself delegates to the shared {@link DropBlankSlotSectionRenderer} of the prompt kernel, which + * owns the drop policy of the sectioned template grammar. 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-08 + */ +class NegotiationPromptRenderer implements SectionedTemplateRenderer { + + private final DropBlankSlotSectionRenderer delegate = new DropBlankSlotSectionRenderer(); + + /** + * Renders one template text with the given slot values. + * + * @param templateText full template text whose slot sections are filled or dropped + * @param slots slot values keyed by the language-specific slot name; a null or blank value drops the slot section + * @return rendered message text with sections joined by one blank line and no trailing newline; empty string when + * no section remains + * @throws NegotiationRenderException if the template text is null; 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) { + if (templateText == null) { + throw new NegotiationRenderException("Negotiation template text must not be null."); + } + return delegate.render(templateText, slots); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java new file mode 100644 index 00000000..90a616c6 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptResourceLoader.java @@ -0,0 +1,77 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.core.resources.PathSegments; + +/** + * Loads negotiation LLM prompt resources from the classpath bundle packaged with the SDK. + * + *

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

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

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

Besides the required target negotiation summary, the message renders round-driven conditional sections: the intent + * understanding section appears only on the first round, the alignment and clarification section only on later rounds, + * and the clarification request section only when clarification items are present. A non-blank confirm request marks + * the "target clarified and requesting confirmation" category instead; the three conditional lists must then be empty, + * so only the summary and the confirm request section remain. + * + * @since 2026-08 + */ +final class TargetProposeGenerator extends AbstractNegotiationGenerator { + + /** + * Generates a target negotiation propose message. + * + * @param context negotiation context of the message + * @param content target propose content + * @param template target propose template to render + * @param vocabulary vocabulary of the message language + * @return rendered target propose message text + * @throws IllegalArgumentException if the content is of another runtime type + * @throws NegotiationGenerationException with the code {@code negotiation.content_invalid} if the summary is blank, + * or {@code negotiation.invalid_input} if the confirm request is combined with any of the three conditional + * sections + */ + @Override + public String generate( + NegotiationContext context, NegotiationContent content, PromptTemplate template, Vocabulary vocabulary) { + TargetProposeContent proposeContent = + contentOf(content, TargetProposeContent.class, "Target propose generator"); + requiredText( + proposeContent.targetNegotiationDescription(), + "content.targetNegotiationDescription", + "Target negotiation description", + vocabulary); + String confirmRequest = presentText(proposeContent.targetConfirmRequest()); + if (confirmRequest != null + && (hasItems(proposeContent.intentUnderstanding()) + || hasItems(proposeContent.alignmentAndClarification()) + || hasItems(proposeContent.requestForClarification()))) { + throw invalidInput( + "Target confirm request must not be combined with the intent understanding, alignment and" + + " clarification or clarification request sections; a confirm-request round carries only" + + " the summary and the confirm request.", + vocabulary); + } + Map slots = new LinkedHashMap<>(); + slots.put(vocabulary.get("slot.target"), proposeContent.targetNegotiationDescription()); + if (context.round() == 1) { + slots.put( + vocabulary.get("slot.target_intent"), + formatItems(proposeContent.intentUnderstanding(), vocabulary)); + } else { + slots.put( + vocabulary.get("slot.target_alignment"), + formatItems(proposeContent.alignmentAndClarification(), vocabulary)); + } + slots.put( + vocabulary.get("slot.target_clarification"), + formatItems(proposeContent.requestForClarification(), vocabulary)); + if (confirmRequest != null) { + slots.put(vocabulary.get("slot.target_confirm_request"), confirmRequest); + } + return render(template, slots); + } + + private static @Nullable String presentText(@Nullable String value) { + return value == null || value.isBlank() ? null : value; + } + + private static NegotiationGenerationException invalidInput(String reason, Vocabulary vocabulary) { + return new NegotiationGenerationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT, vocabulary.language(), Map.of("reason", reason)); + } + + private static boolean hasItems(@Nullable List items) { + return items != null && !items.isEmpty(); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/package-info.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/package-info.java new file mode 100644 index 00000000..ce7c1129 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/generation/package-info.java @@ -0,0 +1,2 @@ +/** Deterministic negotiation message generation: rendering, generators, prompt assembly and content extraction. */ +package net.openan.a2at.sdk.negotiation.generation; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/ClarificationNegotiation.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/ClarificationNegotiation.java deleted file mode 100644 index 6631bae7..00000000 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/ClarificationNegotiation.java +++ /dev/null @@ -1,21 +0,0 @@ -package net.openan.a2at.sdk.negotiation.handler; - -import net.openan.a2at.sdk.negotiation.types.model.NegotiationContext; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationReceiveResult; -import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; - -import java.util.Map; - -/** - * Minimal clarification negotiation behavior. - * - * @since 2026-06 - */ -public final class ClarificationNegotiation implements Negotiation { - - @Override - public NegotiationReceiveResult processReceivedMessage(String message, NegotiationContext context) { - boolean needResponse = context.status() == NegotiationStatus.IN_PROGRESS; - return new NegotiationReceiveResult(needResponse, Map.of(), message); - } -} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/FulfillmentNegotiation.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/TargetNegotiation.java similarity index 84% rename from a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/FulfillmentNegotiation.java rename to a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/TargetNegotiation.java index afacc2e2..1fc246ee 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/FulfillmentNegotiation.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/handler/TargetNegotiation.java @@ -7,11 +7,11 @@ import java.util.Map; /** - * Minimal fulfillment negotiation behavior. + * Minimal target negotiation behavior. * * @since 2026-06 */ -public final class FulfillmentNegotiation implements Negotiation { +public final class TargetNegotiation implements Negotiation { @Override public NegotiationReceiveResult processReceivedMessage(String message, NegotiationContext context) { diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java new file mode 100644 index 00000000..db4abdac --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/DefaultNegotiationTemplateLoader.java @@ -0,0 +1,106 @@ +package net.openan.a2at.sdk.negotiation.resources; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.core.resources.PathSegments; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateDescriptions; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default negotiation template loader, classpath-fixed. + * + *

Templates are resolved from the built-in classpath resources bundled with the SDK — negotiation templates are + * classpath-fixed and never configurable. Local template copies are not consulted. + * + *

Classpath resources are frozen at assembly time: each template is resolved once and cached at the JVM level, so + * every subsequent load returns the cached snapshot. Missing templates throw on every call and are never cached. The + * loader is thread-safe. + * + * @since 2026-08 + */ +public final class DefaultNegotiationTemplateLoader implements NegotiationTemplateLoader { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationTemplateLoader.class); + + private static final String CLASSPATH_ROOT = "prompt_resources/"; + + private static final String TEMPLATE_FILE_NAME = "template.md"; + + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + /** + * Creates a classpath-fixed loader for one language. + * + *

The language is validated here for assembly-time fail-fast only; {@link #load} resolves the language from the + * reference, not from this constructor argument. + * + * @param language locale identifier such as {@code zh-CN} or {@code en-US} + * @throws IllegalArgumentException if the language is not a simple path segment + */ + public DefaultNegotiationTemplateLoader(String language) { + if (!PathSegments.isSimpleSegment(language)) { + throw new IllegalArgumentException( + "Negotiation template loader language must be a non-blank simple path segment but was " + language + + "."); + } + } + + /** + * Loads one negotiation template from the built-in classpath resources. + * + * @param reference template addressing key, including the language to load + * @return loaded template with its URI, description and full content + * @throws ResourceNotFoundException if the template does not exist on the classpath + */ + @Override + public PromptTemplate load(NegotiationReference reference) { + String classpathPath = CLASSPATH_ROOT + templateRelativePath(reference); + CacheKey cacheKey = new CacheKey(classpathPath, Thread.currentThread().getContextClassLoader()); + String content = CACHE.computeIfAbsent(cacheKey, ignored -> readTemplate(classpathPath)); + PromptTemplate template = new PromptTemplate( + reference.templateUri(), + TemplateDescriptions.extract(content), + content, + PromptTemplate.SOURCE_CLASSPATH); + LOGGER.atDebug().log("negotiation_template_loaded uri={} language={}", reference.uri(), reference.language()); + return template; + } + + private static String templateRelativePath(NegotiationReference reference) { + return String.join( + "/", + "templates", + "Negotiation-T", + reference.typeSegment(), + NegotiationReference.uriSegmentOf(reference.performative()), + TemplateUri.DEFAULT_TEMPLATE_VERSION, + reference.language(), + TEMPLATE_FILE_NAME); + } + + private static String readTemplate(String classpathPath) { + InputStream stream = ClasspathResourceStreams.open(classpathPath); + if (stream == null) { + throw new ResourceNotFoundException( + "Negotiation template does not exist for the configured language; set A2AT_LANGUAGE to a" + + " language with bundled templates (zh-CN or en-US).", + classpathPath); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new A2ATError("Failed to read negotiation template: " + classpathPath, exception); + } + } + + private record CacheKey(String classpathPath, @Nullable ClassLoader contextClassLoader) {} +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java new file mode 100644 index 00000000..64e8a90e --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationReference.java @@ -0,0 +1,218 @@ +package net.openan.a2at.sdk.negotiation.resources; + +import java.util.List; +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.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.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Addressing key for one negotiation template: negotiation type, performative and language. + * + *

The reference composes the template URI from the type segment and the URI segment of the performative, so the URI + * spelling has a single source ({@link #uriSegmentOf(NegotiationPerformative)}). The language is query context rather + * than part of the resource identity and is therefore not part of the URI. + * + *

The performative has four values while the template URI layer only distinguishes three segments: {@code ACCEPT} + * and {@code REJECT} share the {@code accept-reject} segment, differing only in the conclusion value filled into the + * template slot, and {@code ABORT} is addressed by the single type-independent common abort template. Typed + * references address the templates of one negotiation type; the abort performative is type-independent and is + * addressed by that common template with a {@code null} type. + * + * @param type negotiation type addressed by the reference; null only for the abort performative, whose common template + * is type-independent + * @param performative communicative intent 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( + @Nullable NegotiationType type, NegotiationPerformative performative, String language) { + + private static final String URI_PREFIX = StandardTemplates.NEGOTIATION_EXTENSION_NAME; + + private static final String URI_VERSION_SEGMENT = TemplateUri.DEFAULT_TEMPLATE_VERSION; + + private static final String TYPE_SEGMENT_SUFFIX = "-negotiation"; + + private static final String COMMON_TYPE_SEGMENT = "common"; + + /** + * Validates the reference fields. + * + * @throws NullPointerException if the performative is null + * @throws IllegalArgumentException if the type is null on a typed performative, non-null on the abort performative, + * or the language is not a simple path segment + */ + public NegotiationReference { + Objects.requireNonNull(performative, "Negotiation reference performative must not be null."); + if (performative == NegotiationPerformative.ABORT && type != null) { + throw new IllegalArgumentException( + "Negotiation reference of the ABORT performative is type-independent; the type must be null but" + + " was " + type + "."); + } + if (performative != NegotiationPerformative.ABORT && type == null) { + throw new IllegalArgumentException( + "Negotiation reference type must not be null for the " + performative + " performative; only the" + + " ABORT performative is type-independent."); + } + if (!PathSegments.isSimpleSegment(language)) { + throw new IllegalArgumentException( + "Negotiation reference language must be a non-blank simple path segment but was " + language + "."); + } + } + + /** + * Returns the template URI segment of a performative. + * + *

The performative has four values but the template URI layer only distinguishes three segments: {@code ACCEPT} + * and {@code REJECT} share the {@code accept-reject} segment. This mapping is the single source of the segment + * spelling. + * + * @param performative performative to map to its template URI segment + * @return {@code propose} for PROPOSE, {@code accept-reject} for ACCEPT and REJECT, and {@code abort} for ABORT + * @throws NullPointerException if the performative is null + */ + public static String uriSegmentOf(NegotiationPerformative performative) { + Objects.requireNonNull(performative, "Negotiation performative must not be null."); + return switch (performative) { + case PROPOSE -> "propose"; + case ACCEPT, REJECT -> "accept-reject"; + case ABORT -> "abort"; + }; + } + + /** + * Returns the hyphenated URI segment of the referenced negotiation type. + * + * @return URI segment such as {@code information-negotiation}; {@code common} for the type-independent abort + * performative + */ + public String typeSegment() { + return type == null ? COMMON_TYPE_SEGMENT : type.typeSegment(); + } + + /** + * Returns the template URI of the referenced template. + * + * @return template URI such as {@code Negotiation-T/information-negotiation/propose/v1} + */ + public String uri() { + return String.join("/", URI_PREFIX, typeSegment(), uriSegmentOf(performative), URI_VERSION_SEGMENT); + } + + /** + * Returns the typed template URI of the referenced template. + * + *

The exact inverse of {@link #fromTemplateUri(TemplateUri, NegotiationPerformative, String)}: the URI is + * composed from the same extension-name constant and default version segment the parser accepts, so + * {@code fromTemplateUri(reference.templateUri(), reference.performative(), ...)} 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(), uriSegmentOf(performative)), URI_VERSION_SEGMENT); + } + + /** + * Tries to parse a template URI into a reference, checking it against the expected performative. + * + *

The URI layer cannot distinguish accept from reject because both share the {@code accept-reject} segment; the + * expected performative disambiguates the parsed result, which therefore always carries the expected performative. + * + * @param templateUri template URI to parse, such as {@code Negotiation-T/target-negotiation/accept-reject/v1} or + * {@code Negotiation-T/common/abort/v1} + * @param expectedPerformative performative the caller is operating on; the parsed reference carries this + * performative + * @param language locale identifier for the parsed reference + * @return reference addressed by the URI carrying the expected performative, or an empty result when the URI is + * null, blank or malformed (wrong segment count, prefix, type segment or trailing version segment) or its URI + * segment does not match the expected performative + * @throws NullPointerException if the expected performative is null + */ + public static Optional tryParse( + @Nullable String templateUri, @NonNull NegotiationPerformative expectedPerformative, String language) { + Objects.requireNonNull(expectedPerformative, "Expected negotiation performative must not be null."); + return TemplateUri.parse(templateUri).flatMap(uri -> fromTemplateUri(uri, expectedPerformative, language)); + } + + /** + * Derives a reference from a typed template URI, checking it against the expected performative. + * + *

The typed variant of {@link #tryParse(String, NegotiationPerformative, 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 performative disambiguates the result, which therefore always + * carries the expected performative. + * + *

The abort performative 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 performative. + * + * @param templateUri typed template URI such as {@code Negotiation-T/target-negotiation/accept-reject/v1} or + * {@code Negotiation-T/common/abort/v1} + * @param expectedPerformative performative the caller is operating on; the derived reference carries this + * performative + * @param language locale identifier for the derived reference + * @return reference addressed by the URI carrying the expected performative, or an empty result when the URI does + * not address a negotiation template of the expected performative (wrong extension name, path segment count, + * type segment, URI segment or template version) + * @throws NullPointerException if the template URI or the expected performative is null + */ + public static Optional fromTemplateUri( + @NonNull TemplateUri templateUri, @NonNull NegotiationPerformative expectedPerformative, String language) { + Objects.requireNonNull(templateUri, "Template URI must not be null."); + Objects.requireNonNull(expectedPerformative, "Expected negotiation performative 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 = null; + if (COMMON_TYPE_SEGMENT.equals(segments.get(0))) { + if (expectedPerformative != NegotiationPerformative.ABORT) { + return Optional.empty(); + } + } else { + parsedType = parseTypeSegment(segments.get(0)); + if (expectedPerformative == NegotiationPerformative.ABORT || parsedType == null) { + return Optional.empty(); + } + } + if (!uriSegmentMatches(segments.get(1), expectedPerformative)) { + return Optional.empty(); + } + if (!URI_VERSION_SEGMENT.equals(templateUri.templateVersion())) { + return Optional.empty(); + } + return Optional.of(new NegotiationReference(parsedType, expectedPerformative, 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 uriSegmentMatches(String uriSegment, NegotiationPerformative expectedPerformative) { + if (!"propose".equals(uriSegment) && !"accept-reject".equals(uriSegment) + && !"abort".equals(uriSegment)) { + return false; + } + return uriSegment.equals(uriSegmentOf(expectedPerformative)); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java new file mode 100644 index 00000000..3e17c667 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/NegotiationTemplateLoader.java @@ -0,0 +1,21 @@ +package net.openan.a2at.sdk.negotiation.resources; + +import net.openan.a2at.sdk.core.model.PromptTemplate; + +/** + * Loads negotiation templates addressed by {@link NegotiationReference} keys. + * + * @since 2026-08 + */ +public interface NegotiationTemplateLoader { + + /** + * Loads one negotiation template. + * + * @param reference template addressing key, including the language to load + * @return loaded template with its URI, description and full content + * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the template does not exist in the + * built-in classpath resources + */ + PromptTemplate load(NegotiationReference reference); +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/package-info.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/package-info.java new file mode 100644 index 00000000..b28a6007 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/resources/package-info.java @@ -0,0 +1,2 @@ +/** Negotiation template resources: addressing keys, template entries and the template loader. */ +package net.openan.a2at.sdk.negotiation.resources; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java index 6ae1092c..bc841dc9 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java @@ -4,6 +4,7 @@ import java.util.Map; import java.util.UUID; +import net.openan.a2at.sdk.core.model.ExtensionUriConstants; import net.openan.a2at.sdk.negotiation.handler.Negotiation; import net.openan.a2at.sdk.negotiation.runtime.helper.NegotiationPayloadMapper; import net.openan.a2at.sdk.negotiation.store.NegotiationStore; @@ -17,11 +18,15 @@ */ public final class NegotiationHandler { - public static final String NEGOTIATION_CONTEXT_KEY = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/DATA-NEGOTIATION-T/v1"; + public static final String NEGOTIATION_T_URI_NL = ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI_NL; - public static final String NEGOTIATION_TEXT_KEY = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/NEGOTIATION-T"; + public static final String NEGOTIATION_T_URI = ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI; + + /** + * @deprecated Use {@link #NEGOTIATION_T_URI_NL} instead. + */ + @Deprecated + public static final String NEGOTIATION_TEXT_KEY = NEGOTIATION_T_URI_NL; private final NegotiationRuntime runtime; diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/helper/NegotiationPayloadMapper.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/helper/NegotiationPayloadMapper.java index f591187c..ecbfd019 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/helper/NegotiationPayloadMapper.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/runtime/helper/NegotiationPayloadMapper.java @@ -17,9 +17,12 @@ public final class NegotiationPayloadMapper { public static Map payload( NegotiationContext context, String contentText, Map facts) { + Map negotiationData = new LinkedHashMap<>(); + negotiationData.put("message", contentText); + negotiationData.putAll(contextPayload(context)); + Map payload = new LinkedHashMap<>(); - payload.put(NegotiationHandler.NEGOTIATION_TEXT_KEY, contentText); - payload.put(NegotiationHandler.NEGOTIATION_CONTEXT_KEY, contextPayload(context)); + payload.put(NegotiationHandler.NEGOTIATION_T_URI_NL, negotiationData); if (!facts.isEmpty()) { payload.put("facts", facts); } @@ -38,6 +41,19 @@ public static Map contextPayload(NegotiationContext context) { return payload; } + @SuppressWarnings("unchecked") + public static Map extractContextMap(Map payload) { + Object primary = payload.get(NegotiationHandler.NEGOTIATION_T_URI_NL); + if (primary instanceof Map data) { + return (Map) data; + } + Object legacy = payload.get(NegotiationHandler.NEGOTIATION_T_URI); + if (legacy instanceof Map data) { + return (Map) data; + } + throw new NegotiationStateException("Negotiation-T extension key not found in payload."); + } + public static NegotiationContext contextFromMap(Map contextMap) { String rawType = stringValue(contextMap, "negotiationType"); String normalizedType = rawType.replace('-', '_').toUpperCase(); diff --git a/a2a-t-negotiation/src/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/types/model/NegotiationType.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/types/model/NegotiationType.java index 700f69d6..4ffaa150 100644 --- a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/types/model/NegotiationType.java +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/types/model/NegotiationType.java @@ -7,7 +7,6 @@ */ public enum NegotiationType { INFORMATION, - CLARIFICATION, - FEASIBILITY, - FULFILLMENT + TARGET, + FEASIBILITY } diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java new file mode 100644 index 00000000..16be4366 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationComplianceChecker.java @@ -0,0 +1,112 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default rule-level compliance checker for negotiation messages. + * + *

The checker validates the negotiation context carried alongside the message in the A2A-T metadata: the id must be + * a UUID of 36 characters in 8-4-4-4-12 hexadecimal form and the round must not exceed the round budget. Error messages + * are rendered from the {@link ErrorCatalog} message templates in the configured language. The positive-integer shape + * of the round fields is already guaranteed by the {@link NegotiationContext} constructor. No other rules are applied: + * the checker does not infer the negotiation type, does not validate conclusion values, does not require ending result + * sections and does not check conditional-section exclusivity. + * + * @since 2026-08 + */ +public final class DefaultNegotiationComplianceChecker implements NegotiationComplianceChecker { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationComplianceChecker.class); + + private static final Pattern UUID_PATTERN = + Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + + private static final String ID_SLOT = "id"; + + private static final String ROUND_SLOT = "round"; + + private final String language; + + /** Creates the default checker rendering messages in {@code en-US}. */ + public DefaultNegotiationComplianceChecker() { + this(null); + } + + /** + * Creates the default checker rendering messages in one language. + * + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + */ + public DefaultNegotiationComplianceChecker(@Nullable String language) { + this.language = language; + } + + /** + * Runs the rule-level compliance check of one negotiation context. + * + * @param context negotiation context carried alongside the message in the A2A-T metadata + * @return rule check outcome with no errors when every context rule holds, otherwise the context rule errors + * @throws NullPointerException if the context is null + */ + @Override + public NegotiationRuleCheckResult check(NegotiationContext context) { + Objects.requireNonNull(context, "context"); + List errors = new ArrayList<>(); + collectIdErrors(context.id(), errors); + if (context.round() > context.maxRounds()) { + errors.add(new SlotValidationError( + ROUND_SLOT, + ErrorCatalog.NEGOTIATION_ROUND_EXCEEDED.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_ROUND_EXCEEDED, language, roundFacts(context)), + roundFacts(context))); + } + return logResult(new NegotiationRuleCheckResult(errors.isEmpty(), List.copyOf(errors))); + } + + private static Map roundFacts(NegotiationContext context) { + return Map.of( + "round", String.valueOf(context.round()), + "max_rounds", String.valueOf(context.maxRounds())); + } + + private void collectIdErrors(String id, List errors) { + if (!UUID_PATTERN.matcher(id).matches()) { + Map facts = Map.of("actual", id); + errors.add(new SlotValidationError( + ID_SLOT, + ErrorCatalog.NEGOTIATION_INVALID_CONTEXT_ID.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_CONTEXT_ID, language, facts), + facts)); + } + } + + /** + * Emits the rule-check completion event of one check outcome. + * + * @param result rule check outcome to log + * @return the unchanged outcome + */ + private static NegotiationRuleCheckResult logResult(NegotiationRuleCheckResult result) { + if (result.errors().isEmpty()) { + LOGGER.atDebug().log("negotiation_rule_checks_completed passed={} error_count=0", result.passed()); + } else { + LOGGER.atWarn() + .log( + "negotiation_rule_checks_completed passed={} error_count={}", + result.passed(), + result.errors().size()); + } + return result; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java new file mode 100644 index 00000000..a392e1cd --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/DefaultNegotiationSemanticValidator.java @@ -0,0 +1,417 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.UnaryOperator; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.resources.ClasspathResourceStreams; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Default LLM-backed semantic validator for negotiation messages. + * + *

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

After the call the validator enforces the output contract in code: the response must contain the four required + * keys with the expected shapes, otherwise the internal {@link NegotiationValidationException} is thrown for the + * orchestration layer to map to the retryable {@code llm.response_invalid} code; a transport failure of the LLM call + * propagates as {@link net.openan.a2at.sdk.llm.LLMError} for the orchestration layer to map to + * {@code llm.invocation_failed}. The LLM reports each error as {@code {slot_name, code, facts}}; the human-readable + * message is rendered from the code's message template in the reference language, never taken from the LLM response, + * and a code outside the closed {@code negotiation.*} set of the catalog is mapped to the fallback + * {@code negotiation.rule_violation} and logged as a warning. When the verdict is true, the reported negotiation type + * must be present and must match the type declared by the template reference; a null or mismatching type turns the + * outcome into a semantic rejection carrying a {@code negotiation.type_mismatch} error. Phase consistency between the + * declared template and the message sections is part of the semantic tasks performed by the LLM and surfaces through + * the returned errors. + * + * @since 2026-08 + */ +public final class DefaultNegotiationSemanticValidator implements NegotiationSemanticValidator { + + private static final Logger LOGGER = LoggerFactory.getLogger(DefaultNegotiationSemanticValidator.class); + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String PROMPT_RESOURCE_ROOT = "prompt_resources/prompts/negotiation_semantic_validation/"; + + private static final String SYSTEM_PROMPT_FILE = "system.md"; + + private static final String USER_PROMPT_FILE = "user.md"; + + private static final String KEY_SEMANTIC_VERDICT = "semantic_verdict"; + + private static final String KEY_NEGOTIATION_TYPE = "negotiation_type"; + + private static final String KEY_ERRORS = "errors"; + + private static final String KEY_PARAMS = "params"; + + private static final String KEY_SLOT_NAME = "slot_name"; + + private static final String KEY_CODE = "code"; + + private static final String KEY_FACTS = "facts"; + + private static final String NEGOTIATION_CODE_DOMAIN = "negotiation."; + + private final LLMClient llmClient; + + private static final List NEGOTIATION_TYPE_ENUM = + Arrays.asList("information", "target", "feasibility", null); + + private final UnaryOperator> schemaBuilder; + + /** + * Creates the default semantic validator merging the caller schema with the built-in semantic validation output + * contract. + * + * @param llmClient LLM client used for the single structured call + * @throws NullPointerException if the LLM client is null + */ + public DefaultNegotiationSemanticValidator(LLMClient llmClient) { + this(llmClient, DefaultNegotiationSemanticValidator::buildSemanticValidationSchema); + } + + /** + * Creates the default semantic validator with a custom schema-merging collaborator. + * + * @param llmClient LLM client used for the single structured call + * @param schemaBuilder collaborator merging the caller schema into the semantic validation output contract + * @throws NullPointerException if the LLM client or the schema builder is null + */ + DefaultNegotiationSemanticValidator(LLMClient llmClient, UnaryOperator> schemaBuilder) { + this.llmClient = Objects.requireNonNull(llmClient, "llmClient"); + this.schemaBuilder = Objects.requireNonNull(schemaBuilder, "schemaBuilder"); + } + + /** + * Merges the caller-provided parameter schema into the semantic validation output contract. + * + *

The merged schema requires exactly the four keys {@code semantic_verdict}, {@code negotiation_type}, + * {@code errors} and {@code params} and allows no additional properties. The caller schema is embedded as the + * {@code params} property; a caller schema without a {@code type} keyword is wrapped as an object schema first. + * + * @param callerSchema parameter schema provided by the caller of the validation API + * @return merged JSON Schema of the semantic validation LLM call + * @throws NullPointerException if the caller schema is null + */ + static Map buildSemanticValidationSchema(Map callerSchema) { + Objects.requireNonNull(callerSchema, "Caller parameter schema must not be null."); + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + Map properties = new LinkedHashMap<>(); + properties.put("semantic_verdict", Map.of("type", "boolean")); + Map negotiationType = new LinkedHashMap<>(); + negotiationType.put("type", List.of("string", "null")); + negotiationType.put("enum", NEGOTIATION_TYPE_ENUM); + properties.put("negotiation_type", negotiationType); + properties.put("errors", errorsSchema()); + properties.put("params", wrapCallerSchema(callerSchema)); + schema.put("properties", properties); + schema.put("required", List.of("semantic_verdict", "negotiation_type", "errors", "params")); + schema.put("additionalProperties", false); + return schema; + } + + private static Map errorsSchema() { + Map factsProperties = new LinkedHashMap<>(); + factsProperties.put("type", "object"); + factsProperties.put("additionalProperties", Map.of("type", "string")); + Map errorProperties = new LinkedHashMap<>(); + errorProperties.put(KEY_SLOT_NAME, Map.of("type", "string")); + errorProperties.put(KEY_CODE, Map.of("type", "string")); + errorProperties.put(KEY_FACTS, factsProperties); + Map errorItem = new LinkedHashMap<>(); + errorItem.put("type", "object"); + errorItem.put("properties", errorProperties); + errorItem.put("required", List.of(KEY_SLOT_NAME, KEY_CODE, KEY_FACTS)); + errorItem.put("additionalProperties", false); + Map errors = new LinkedHashMap<>(); + errors.put("type", "array"); + errors.put("items", errorItem); + return errors; + } + + private static Map wrapCallerSchema(Map callerSchema) { + if (callerSchema.containsKey("type")) { + return callerSchema; + } + Map wrapped = new LinkedHashMap<>(); + wrapped.put("type", "object"); + wrapped.putAll(callerSchema); + return wrapped; + } + + /** + * Validates one rendered negotiation message semantically and extracts its parameters in a single LLM call. + * + * @param prompt rendered negotiation message text + * @param callerSchema caller-provided parameter JSON schema embedded into the structured-call output contract + * @param reference template reference the message is validated against, carrying the declared type, performative + * and language + * @return semantic validation outcome carrying the verdict, the implied negotiation type, the semantic errors and + * the extracted parameters + * @throws NegotiationValidationException if the response misses a required key or has the wrong shape + * @throws net.openan.a2at.sdk.llm.LLMError if the LLM invocation fails at the transport level + * @throws ResourceNotFoundException if the semantic validation prompt resources of the reference language are + * missing + */ + @Override + public SemanticValidationResult validateNegotiation( + String prompt, Map callerSchema, NegotiationReference reference, String templateContent) { + Objects.requireNonNull(prompt, "prompt"); + Objects.requireNonNull(reference, "reference"); + List> messages = buildMessages(prompt, callerSchema, reference, templateContent); + Map mergedSchema = schemaBuilder.apply(callerSchema); + LLMResponse response = llmClient.structured(messages, mergedSchema, null, null); + SemanticValidationResult result = interpret(parseResponse(response.content()), reference); + LOGGER.atInfo().log( + "negotiation_semantic_validation_completed verdict={} error_count={}", + result.verdict(), + result.errors().size()); + return result; + } + + private static final Pattern USER_PROMPT_PLACEHOLDER_PATTERN = + Pattern.compile("\\[(phase|input|template_uri|negotiation_type|template_content|schema)\\]"); + + private static List> buildMessages( + String prompt, Map callerSchema, NegotiationReference reference, String templateContent) { + String language = reference.language(); + String systemPrompt = loadPromptResource(SYSTEM_PROMPT_FILE, language); + String userPrompt = loadPromptResource(USER_PROMPT_FILE, language); + Matcher matcher = USER_PROMPT_PLACEHOLDER_PATTERN.matcher(userPrompt); + StringBuilder sb = new StringBuilder(); + while (matcher.find()) { + String replacement = + switch (matcher.group(1)) { + case "phase" -> Matcher.quoteReplacement( + reference.performative().name().toLowerCase(Locale.ROOT)); + case "input" -> Matcher.quoteReplacement(prompt); + case "template_uri" -> Matcher.quoteReplacement(reference.uri()); + case "negotiation_type" -> Matcher.quoteReplacement(declaredTypeName(reference)); + case "template_content" -> Matcher.quoteReplacement(templateContent); + case "schema" -> Matcher.quoteReplacement(toJson(callerSchema)); + default -> Matcher.quoteReplacement(matcher.group()); + }; + matcher.appendReplacement(sb, replacement); + } + matcher.appendTail(sb); + String filledUserPrompt = sb.toString(); + return List.of( + Map.of("role", "system", "content", systemPrompt), Map.of("role", "user", "content", filledUserPrompt)); + } + + private static String loadPromptResource(String fileName, String language) { + String classpathPath = PROMPT_RESOURCE_ROOT + language + "/" + fileName; + InputStream stream = ClasspathResourceStreams.open(classpathPath); + if (stream == null) { + throw new ResourceNotFoundException( + "Negotiation semantic validation prompt resource does not exist for language " + language + + "; set A2AT_LANGUAGE to a language with bundled prompt resources (zh-CN or en-US).", + classpathPath); + } + try (stream) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new A2ATError("Failed to read negotiation semantic validation prompt: " + classpathPath, exception); + } + } + + private static String toJson(Map callerSchema) { + try { + return OBJECT_MAPPER.writeValueAsString(callerSchema == null ? Map.of() : callerSchema); + } catch (JsonProcessingException error) { + throw new NegotiationValidationException("Failed to serialize the caller parameter schema.", error); + } + } + + private static Map parseResponse(String content) { + if (content == null || content.isBlank()) { + throw new NegotiationValidationException("Semantic validation response is empty."); + } + try { + Map parsed = OBJECT_MAPPER.readValue(content, new TypeReference>() {}); + return parsed == null ? Map.of() : parsed; + } catch (JsonProcessingException error) { + throw new NegotiationValidationException( + "Semantic validation response is not a JSON object: " + error.getMessage(), error); + } + } + + private static SemanticValidationResult interpret(Map response, NegotiationReference reference) { + if (!(response.get(KEY_SEMANTIC_VERDICT) instanceof Boolean verdict)) { + throw new NegotiationValidationException( + "Semantic validation response key semantic_verdict must be a boolean."); + } + if (!response.containsKey(KEY_NEGOTIATION_TYPE)) { + throw new NegotiationValidationException( + "Semantic validation response is missing the required key negotiation_type."); + } + Object typeValue = response.get(KEY_NEGOTIATION_TYPE); + if (typeValue != null && !(typeValue instanceof String)) { + throw new NegotiationValidationException( + "Semantic validation response key negotiation_type must be a string or null."); + } + if (!(response.get(KEY_ERRORS) instanceof List rawErrors)) { + throw new NegotiationValidationException("Semantic validation response key errors must be an array."); + } + if (!(response.get(KEY_PARAMS) instanceof Map rawParams)) { + throw new NegotiationValidationException("Semantic validation response key params must be an object."); + } + + List errors = parseErrors(rawErrors, reference.language()); + Map params = parseParams(rawParams); + String negotiationType = (String) typeValue; + + if (verdict && reference.type() != null) { + if (negotiationType == null) { + List rejectionErrors = new ArrayList<>(errors); + rejectionErrors.add(typeConsistencyError( + reference.type(), "unknown", declaredTypeName(reference), reference.language())); + return new SemanticValidationResult(false, null, rejectionErrors, params); + } + NegotiationType impliedType = parseImpliedType(negotiationType); + if (impliedType != reference.type()) { + NegotiationType sectionType = impliedType == null ? reference.type() : impliedType; + List rejectionErrors = new ArrayList<>(errors); + rejectionErrors.add(typeConsistencyError( + sectionType, negotiationType, declaredTypeName(reference), reference.language())); + return new SemanticValidationResult(false, negotiationType, rejectionErrors, params); + } + } + return new SemanticValidationResult(verdict, negotiationType, errors, params); + } + + private static List parseErrors(List rawErrors, String language) { + List errors = new ArrayList<>(); + for (Object rawError : rawErrors) { + if (!(rawError instanceof Map errorMap)) { + throw new NegotiationValidationException( + "Semantic validation response errors must be objects with slot_name, code and facts."); + } + if (!(errorMap.get(KEY_SLOT_NAME) instanceof String slotName) + || !(errorMap.get(KEY_CODE) instanceof String code)) { + throw new NegotiationValidationException( + "Semantic validation response errors must carry string slot_name and code values."); + } + if (!(errorMap.get(KEY_FACTS) instanceof Map rawFacts)) { + throw new NegotiationValidationException( + "Semantic validation response errors must carry a facts object."); + } + Map facts = stringFacts(rawFacts); + ErrorCatalog entry = resolveCode(code); + if (!entry.getCode().equals(code)) { + LOGGER.atWarn() + .log( + "negotiation_semantic_validation_unknown_code original_code={} fallback_code={}", + code, + entry.getCode()); + facts = Map.of("section_label", slotName); + } + errors.add(new SlotValidationError( + slotName, entry.getCode(), ErrorMessages.render(entry, language, facts), facts)); + } + return errors; + } + + /** Resolves one LLM-reported code to its catalog entry, falling back to {@code negotiation.rule_violation}. */ + private static ErrorCatalog resolveCode(String code) { + Optional entry = ErrorCatalog.byCode(code); + if (entry.isPresent() && entry.get().getCode().startsWith(NEGOTIATION_CODE_DOMAIN)) { + return entry.get(); + } + return ErrorCatalog.NEGOTIATION_RULE_VIOLATION; + } + + private static Map stringFacts(Map rawFacts) { + Map facts = new LinkedHashMap<>(); + for (Map.Entry entry : rawFacts.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + continue; + } + Object value = entry.getValue(); + if (value instanceof String text) { + facts.put(key, text); + } else if (value instanceof Number || value instanceof Boolean) { + facts.put(key, String.valueOf(value)); + } + } + return Map.copyOf(facts); + } + + private static Map parseParams(Map rawParams) { + Map params = new LinkedHashMap<>(); + for (Map.Entry entry : rawParams.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + throw new NegotiationValidationException("Semantic validation response params keys must be strings."); + } + params.put(key, entry.getValue()); + } + return params; + } + + private static SlotValidationError typeConsistencyError( + NegotiationType sectionType, String implied, String declared, String language) { + Map facts = Map.of("implied", implied, "declared", declared); + return new SlotValidationError( + declaredTypeSectionKey(sectionType), + ErrorCatalog.NEGOTIATION_TYPE_MISMATCH.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_TYPE_MISMATCH, language, facts), + facts); + } + + /** Parses the negotiation type reported for the message; null when the reported type is none of the known types. */ + private static NegotiationType parseImpliedType(String negotiationType) { + for (NegotiationType candidate : NegotiationType.values()) { + if (candidate.name().toLowerCase(Locale.ROOT).equals(negotiationType)) { + return candidate; + } + } + return null; + } + + private static String declaredTypeName(NegotiationReference reference) { + return reference.type() == null ? "common" : reference.type().name().toLowerCase(Locale.ROOT); + } + + private static String declaredTypeSectionKey(NegotiationType type) { + switch (type) { + case INFORMATION: + return "section.info_static"; + case TARGET: + return "section.target"; + case FEASIBILITY: + return "section.feasibility"; + default: + throw new NegotiationValidationException("Unknown negotiation type " + type + "."); + } + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java new file mode 100644 index 00000000..ba6641e8 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationComplianceChecker.java @@ -0,0 +1,26 @@ +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 new file mode 100644 index 00000000..882c2c8b --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckResult.java @@ -0,0 +1,26 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.List; +import net.openan.a2at.sdk.core.model.SlotValidationError; + +/** + * Outcome of the rule-level compliance check of a negotiation message. + * + *

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 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, List errors) { + + /** + * 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 new file mode 100644 index 00000000..9ee69288 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationRuleCheckerAdapter.java @@ -0,0 +1,97 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.RuleChecker; +import org.jspecify.annotations.Nullable; + +/** + * Adapter that bridges {@link NegotiationComplianceChecker} to the {@link RuleChecker} contract for one validation + * call. + * + *

The adapter holds a {@link NegotiationComplianceChecker} and the negotiation context carried alongside the message + * in the A2A-T metadata, delegating the {@link RuleChecker#check(String)} call to the context-aware + * {@link NegotiationComplianceChecker#check(NegotiationContext)} method. A null context is reported as not being a + * negotiation message. The resulting {@link NegotiationRuleCheckResult} is converted into either a map of context + * parameters ({@code id}, {@code round}, {@code maxRounds}) or a {@link ContentValidationException} carrying the + * catalog codes {@code negotiation.invalid_input} (no negotiation context) or {@code negotiation.rule_violation} + * (context rule violation) with messages rendered in the configured language. + * + * @since 2026-08 + */ +public final class NegotiationRuleCheckerAdapter implements RuleChecker { + + private static final String MISSING_CONTEXT_REASON_EN = + "the negotiation context is missing (the message is not a negotiation message)"; + + private static final String MISSING_CONTEXT_REASON_ZH = "缺少协商上下文(该报文不是协商报文)"; + + private final NegotiationComplianceChecker checker; + + private final NegotiationContext context; + + private final String language; + + /** + * Creates an adapter for one compliance checker and negotiation context rendering messages in {@code en-US}. + * + * @param checker compliance checker validating the negotiation context + * @param context negotiation context carried alongside the message; null is reported as not being a negotiation + * message + */ + public NegotiationRuleCheckerAdapter(NegotiationComplianceChecker checker, @Nullable NegotiationContext context) { + this(checker, context, null); + } + + /** + * Creates an adapter for one compliance checker, negotiation context and message language. + * + * @param checker compliance checker validating the negotiation context + * @param context negotiation context carried alongside the message; null is reported as not being a negotiation + * message + * @param language message language, for example {@code zh-CN}; null or blank falls back to {@code en-US} + */ + public NegotiationRuleCheckerAdapter( + NegotiationComplianceChecker checker, @Nullable NegotiationContext context, @Nullable String language) { + this.checker = Objects.requireNonNull(checker, "checker"); + this.context = context; + this.language = language; + } + + @Override + public Map check(String prompt) { + if (context == null) { + Map facts = Map.of("reason", missingContextReason()); + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), + ErrorMessages.render(ErrorCatalog.NEGOTIATION_INVALID_INPUT, language, facts), + List.of(), + Map.of(), + null); + } + NegotiationRuleCheckResult result = checker.check(context); + if (!result.passed()) { + String message = result.errors().isEmpty() + ? ErrorMessages.render(ErrorCatalog.NEGOTIATION_RULE_VIOLATION, language, null) + : result.errors().get(0).message(); + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), message, result.errors()); + } + Map params = new LinkedHashMap<>(); + params.put("id", context.id()); + params.put("round", context.round()); + params.put("maxRounds", context.maxRounds()); + return Collections.unmodifiableMap(params); + } + + private String missingContextReason() { + return language != null && language.startsWith("zh") ? MISSING_CONTEXT_REASON_ZH : MISSING_CONTEXT_REASON_EN; + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java new file mode 100644 index 00000000..fe1955e0 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationSemanticValidator.java @@ -0,0 +1,80 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.SemanticValidator; +import net.openan.a2at.sdk.core.validation.ValidationResult; +import net.openan.a2at.sdk.llm.LLMConfigError; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; + +/** + * LLM-backed semantic validator for negotiation messages. + * + *

The validator performs a single structured LLM call that combines semantic validation with parameter extraction + * and then enforces the declared type consistency in code: when the verdict is true, the negotiation type reported for + * the message must be present and must match the type declared by the template reference. A response that misses one of + * the four required keys or has the wrong shape is a validation infrastructure failure signalled through the internal + * {@link NegotiationValidationException}. + * + * @since 2026-08 + */ +public interface NegotiationSemanticValidator extends SemanticValidator { + + /** + * Validates one rendered negotiation message semantically and extracts its parameters. + * + * @param prompt rendered negotiation message text + * @param callerSchema caller-provided parameter JSON schema embedded into the structured-call output contract + * @param reference negotiation reference the message is validated against, carrying the declared type, phase and + * language + * @param templateContent loaded template text used as a reference for structure/completeness checks + * @return semantic validation outcome carrying the verdict, the implied negotiation type, the semantic errors and + * the extracted parameters + * @throws NegotiationValidationException if the response misses a required key or has the wrong shape + * @throws net.openan.a2at.sdk.llm.LLMError if the LLM invocation fails at the transport level + * @throws net.openan.a2at.sdk.core.exception.ResourceNotFoundException if the semantic validation prompt resources + * of the reference language are missing + */ + SemanticValidationResult validateNegotiation( + String prompt, Map callerSchema, NegotiationReference reference, String templateContent); + + @Override + default ValidationResult validate( + String prompt, Map schema, NegotiationReference reference, String templateContent) { + try { + SemanticValidationResult result = validateNegotiation(prompt, schema, reference, templateContent); + return new ValidationResult(result.verdict(), result.errors(), result.params()); + } catch (LLMConfigError exception) { + // A missing LLM configuration never recovers within this call. + throw new ContentValidationException( + ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), + ErrorMessages.render(ErrorCatalog.LLM_NOT_CONFIGURED, reference.language(), null), + exception); + } catch (LLMError exception) { + Map facts = Map.of( + "provider", exception.getClass().getSimpleName(), + "reason", String.valueOf(exception.getMessage())); + String message = ErrorMessages.render(ErrorCatalog.LLM_INVOCATION_FAILED, reference.language(), facts); + throw new ContentValidationException( + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + message, + List.of(new SlotValidationError( + "_llm", ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), message, facts)), + exception); + } catch (NegotiationValidationException exception) { + Map facts = Map.of("step", "semantic_validation"); + String message = ErrorMessages.render(ErrorCatalog.LLM_RESPONSE_INVALID, reference.language(), facts); + throw new ContentValidationException( + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + message, + List.of(new SlotValidationError( + "_llm", ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), message, facts)), + exception); + } + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java new file mode 100644 index 00000000..a5027b8a --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/NegotiationValidationException.java @@ -0,0 +1,33 @@ +package net.openan.a2at.sdk.negotiation.validation; + +/** + * Internal failure of the negotiation semantic validation step. + * + *

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

The extractor delegates to the shared {@link ValidationPipeline} from the core module, which runs the rule-level + * gate, the template loading gate, retryable semantic validation and deterministic parameter merging in one pass. The + * rule-level gate is a per-call {@link NegotiationRuleCheckerAdapter} bridging the negotiation context carried + * alongside the message to the core rule checker contract; the injected {@link TemplateContentLoader} resolves the + * template body after the rule gate and before semantic validation, so the template is never preloaded by the caller. + * The pipeline emits the final {@link net.openan.a2at.sdk.core.exception.ErrorCatalog} codes with messages rendered in + * the language of the negotiation reference; there is no code mapping layer between the pipeline steps and the surfaced + * exception. + * + * @since 2026-08 + */ +public final class ParamExtractor { + + private final NegotiationComplianceChecker complianceChecker; + + private final SemanticValidator semanticValidator; + + private final int maxAttempts; + + private final TemplateContentLoader templateContentLoader; + + /** + * Creates a parameter extractor. + * + * @param complianceChecker rule-level checker used as the entry gate + * @param semanticValidator LLM-backed semantic validator producing the semantic verdict and extracted parameters + * @param maxAttempts maximum number of retry attempts for the semantic validation step + * @param templateContentLoader template loading gate resolving the template body after the rule gate + * @throws NullPointerException if any collaborator is null + */ + public ParamExtractor( + NegotiationComplianceChecker complianceChecker, + SemanticValidator semanticValidator, + int maxAttempts, + TemplateContentLoader templateContentLoader) { + this.complianceChecker = Objects.requireNonNull(complianceChecker, "complianceChecker"); + this.semanticValidator = Objects.requireNonNull(semanticValidator, "semanticValidator"); + this.maxAttempts = maxAttempts; + this.templateContentLoader = Objects.requireNonNull(templateContentLoader, "templateContentLoader"); + } + + /** + * Validates one negotiation message and extracts its parameters through the full pipeline. + * + * @param prompt rendered negotiation message text + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param reference template reference the message is validated against + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NegotiationParamExtractionException with the code {@code negotiation.invalid_input}, + * {@code negotiation.rule_violation}, {@code negotiation.semantic_rejected}, {@code llm.invocation_failed}, + * {@code llm.response_invalid} or {@code template.not_found} when the validation pipeline fails + */ + public FilledParamData extract( + String prompt, + @Nullable NegotiationContext context, + Map schema, + NegotiationReference reference) { + ValidationPipeline pipeline = new ValidationPipeline<>( + new NegotiationRuleCheckerAdapter(complianceChecker, context, reference.language()), + semanticValidator, + maxAttempts, + reference.language(), + templateContentLoader); + try { + return pipeline.validate(prompt, schema, reference); + } catch (ContentValidationException e) { + throw negotiationFailure(e, reference.language()); + } + } + + private static NegotiationParamExtractionException negotiationFailure( + ContentValidationException failure, String language) { + // The pipeline renders the final message itself; only the structured facts are reconstructed from the first + // slot detail that carries them, so callers keep the rendered message and gain the facts map. + ErrorCatalog entry = ErrorCatalog.byCode(failure.getCode()).orElse(null); + if (entry == null) { + throw new NegotiationParamExtractionException( + failure.getCode(), failure.getMessage(), failure.errors(), failure); + } + Map facts = failure.errors().stream() + .map(SlotValidationError::facts) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + return new NegotiationParamExtractionException( + entry.getCode(), failure.getMessage(), facts, failure.errors(), failure); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java new file mode 100644 index 00000000..f2dbac06 --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/SemanticValidationResult.java @@ -0,0 +1,35 @@ +package net.openan.a2at.sdk.negotiation.validation; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.SlotValidationError; + +/** + * Outcome of the LLM-backed semantic validation of a negotiation message. + * + *

The semantic validation step combines the semantic verdict, the negotiation type implied by the message sections, + * the structured semantic errors and the parameters extracted from the message per the caller-provided schema. + * + * @param verdict overall semantic verdict; {@code true} only when every semantic constraint holds + * @param negotiationType negotiation type implied by the message sections, one of {@code information}, {@code target} + * or {@code feasibility}; may be null when the verdict is false + * @param errors structured semantic errors using language-neutral {@code section.*} slot names; empty when the verdict + * is true + * @param params parameters extracted from the message per the caller-provided schema + * @since 2026-08 + */ +public record SemanticValidationResult( + boolean verdict, String negotiationType, List errors, Map params) { + + /** + * Normalizes the errors and the extracted parameters. + * + * @throws NullPointerException if the errors list or the params map is null + */ + public SemanticValidationResult { + errors = List.copyOf(errors); + params = Collections.unmodifiableMap(new LinkedHashMap<>(params)); + } +} diff --git a/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/package-info.java b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/package-info.java new file mode 100644 index 00000000..353b225e --- /dev/null +++ b/a2a-t-negotiation/src/main/java/net/openan/a2at/sdk/negotiation/validation/package-info.java @@ -0,0 +1,2 @@ +/** Rule and semantic validation of rendered negotiation messages plus parameter extraction. */ +package net.openan.a2at.sdk.negotiation.validation; diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/DeprecatedAnnotationScanTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/DeprecatedAnnotationScanTest.java new file mode 100644 index 00000000..d47c4f7f --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/DeprecatedAnnotationScanTest.java @@ -0,0 +1,64 @@ +package net.openan.a2at.sdk.negotiation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +/** + * Guards the compatibility promise of the negotiation content layer: no API of the pre-existing negotiation state + * machine may gain a {@code @Deprecated} marker now that the content layer exists. The scan pins the exact set of + * pre-existing {@code @Deprecated} annotations of the module main sources, so any newly added annotation fails the + * test. + */ +class DeprecatedAnnotationScanTest { + + private static final Path MAIN_SOURCES = Path.of("src", "main", "java"); + + /** The only @Deprecated markers that existed before the negotiation content layer was added. */ + private static final List PINNED_DEPRECATED_ANNOTATIONS = + List.of("net/openan/a2at/sdk/negotiation/runtime/NegotiationHandler.java"); + + @Test + void noNewDeprecatedAnnotationsWereAddedToTheModuleMainSources() throws IOException { + Assumptions.assumeTrue( + Files.isDirectory(MAIN_SOURCES), "module main sources are not visible from the test working directory"); + + List annotatedFiles; + try (Stream sources = Files.walk(MAIN_SOURCES)) { + annotatedFiles = sources.filter(Files::isRegularFile) + .filter(DeprecatedAnnotationScanTest::containsDeprecatedAnnotation) + .map(path -> MAIN_SOURCES.relativize(path).toString().replace('\\', '/')) + .sorted() + .toList(); + } + + assertEquals( + PINNED_DEPRECATED_ANNOTATIONS, + annotatedFiles, + "the negotiation module must not gain new @Deprecated annotations; the pinned set is the" + + " pre-content-layer baseline"); + assertTrue( + annotatedFiles.stream() + .noneMatch(file -> file.startsWith("content") + || file.startsWith("generation") + || file.startsWith("validation") + || file.startsWith("resources")), + "none of the negotiation content layer packages may carry @Deprecated annotations"); + } + + private static boolean containsDeprecatedAnnotation(Path file) { + try { + return Files.readString(file, StandardCharsets.UTF_8).contains("@Deprecated"); + } catch (IOException exception) { + return false; + } + } +} 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 new file mode 100644 index 00000000..15cf9704 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationConcurrentMixedLoadTest.java @@ -0,0 +1,169 @@ +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.core.model.NegotiationPerformative; +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, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)); + MetadataContent fromDataBase = orchestrator.generateProposeFromData(proposeData, INFORMATION_PROPOSE_URI); + MetadataContent fromTextBase = orchestrator.generateProposeFromText( + "请提供节能区域信息。", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE_URI); + FilledParamData validateBase = orchestrator.validateProposePromptAndDataFilling( + fromDataBase.promptText(), + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + 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, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE_URI); + assertEquals(fromTextBase.promptText(), result.promptText(), "from-text baseline"); + })); + } else { + validateThreads++; + workers.add(callableOf(() -> { + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + fromDataBase.promptText(), + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + 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 new file mode 100644 index 00000000..d64e576f --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/concurrency/NegotiationContextImmutabilityTest.java @@ -0,0 +1,99 @@ +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.core.model.NegotiationPerformative; +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, NegotiationPerformative.PROPOSE); + + 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"); + assertEquals( + NegotiationPerformative.PROPOSE, + shared.performative(), + "original performative 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, NegotiationPerformative.PROPOSE), derived.iterator().next()); + assertEquals( + NegotiationPerformative.PROPOSE, + derived.iterator().next().performative(), + "the derived context of the next round keeps the performative of this round"); + } finally { + pool.shutdownNow(); + } + } + + @Test + void ruleCheckerIsPureUnderConcurrentCalls() throws Exception { + NegotiationContext context = new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE); + DefaultNegotiationComplianceChecker checker = new DefaultNegotiationComplianceChecker(); + NegotiationRuleCheckResult baseline = checker.check(context); + + 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(context); + assertEquals(baseline.passed(), result.passed()); + assertEquals(baseline.errors(), result.errors()); + } + 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/content/NegotiationExceptionHierarchyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java new file mode 100644 index 00000000..64c8eb4e --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/NegotiationExceptionHierarchyTest.java @@ -0,0 +1,111 @@ +package net.openan.a2at.sdk.negotiation.content; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.List; +import net.openan.a2at.sdk.core.exception.A2ATBusinessException; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import org.junit.jupiter.api.Test; + +class NegotiationExceptionHierarchyTest { + + private static final Class[] NEGOTIATION_EXCEPTION_TYPES = { + NegotiationProcessingException.class, + NegotiationGenerationException.class, + NegotiationParamExtractionException.class + }; + + @Test + void processingExceptionIsABusinessFailureWithCode() { + NegotiationProcessingException exception = + new NegotiationProcessingException(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), "invalid input"); + + assertTrue(exception instanceof A2ATBusinessException); + assertTrue(exception instanceof A2ATError); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); + } + + @Test + void processingExceptionRendersItsMessageFromTheCatalogTemplate() { + NegotiationProcessingException exception = new NegotiationProcessingException( + ErrorCatalog.NEGOTIATION_CONTENT_INVALID, + "zh-CN", + java.util.Map.of("field", "content.terminationReason", "reason", "blank")); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), exception.getCode()); + assertEquals("协商内容字段「content.terminationReason」无效:blank", exception.getMessage()); + assertEquals("content.terminationReason", exception.getFacts().get("field")); + } + + @Test + void processingExceptionInheritsGetCodeFromTheA2ATErrorRoot() { + NegotiationProcessingException exception = + new NegotiationProcessingException(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), "invalid input"); + + assertEquals( + A2ATError.class, + findGetMethod(NegotiationProcessingException.class).getDeclaringClass(), + "the code accessor must come from the A2ATError root so every negotiation failure shares one code" + + " contract"); + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), exception.getCode()); + } + + @Test + void generationExceptionExtendsProcessingException() { + NegotiationGenerationException exception = + new NegotiationGenerationException(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), "missing field"); + + assertTrue(exception instanceof NegotiationProcessingException); + assertTrue(exception instanceof A2ATBusinessException); + assertTrue(exception instanceof A2ATError); + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); + } + + @Test + void paramExtractionExceptionIsABusinessFailureCarryingSlotDetails() { + NegotiationParamExtractionException codedException = new NegotiationParamExtractionException( + ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), "rule violated", List.of()); + + assertTrue(codedException instanceof A2ATBusinessException); + assertTrue(codedException instanceof A2ATError); + assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), codedException.getCode()); + assertTrue(codedException.getErrors().isEmpty()); + } + + @Test + void paramExtractionExceptionRendersItsMessageAndKeepsTheCause() { + IllegalStateException cause = new IllegalStateException("endpoint unavailable"); + NegotiationParamExtractionException exception = new NegotiationParamExtractionException( + ErrorCatalog.LLM_INVOCATION_FAILED, + "zh-CN", + java.util.Map.of("provider", "OpenAIClient", "reason", "endpoint unavailable"), + List.of(), + cause); + + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), exception.getCode()); + assertEquals("LLM 调用失败(提供方 OpenAIClient):endpoint unavailable", exception.getMessage()); + assertEquals("endpoint unavailable", exception.getFacts().get("reason")); + assertEquals(cause, exception.getCause()); + } + + @Test + void noNegotiationExceptionExposesAStageProperty() { + for (Class exceptionType : NEGOTIATION_EXCEPTION_TYPES) { + boolean exposesStage = Arrays.stream(exceptionType.getMethods()) + .anyMatch(method -> "getStage".equals(method.getName()) || "stage".equals(method.getName())); + assertFalse(exposesStage, exceptionType.getSimpleName() + " must not expose a stage property"); + } + } + + private static java.lang.reflect.Method findGetMethod(Class exceptionType) { + try { + return exceptionType.getMethod("getCode"); + } catch (NoSuchMethodException exception) { + throw new AssertionError("getCode must be inherited from A2ATError", exception); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java new file mode 100644 index 00000000..26f7d24f --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/content/VocabularyTest.java @@ -0,0 +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")); + } +} 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 new file mode 100644 index 00000000..e93bfe1d --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationDeliveredResourceContractTest.java @@ -0,0 +1,126 @@ +package net.openan.a2at.sdk.negotiation.contract; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import net.openan.a2at.sdk.core.exception.A2ATErrorCodes; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +/** + * Guards the delivered negotiation resources and the error-code constant registry. + * + *

The twelve templates resolved from the test classpath must be byte-identical to the delivered template files of + * the working tree, so that no packaging or formatting step can silently rewrite the authoritative template bytes. The + * deprecated error-code constants must stay exactly the pinned forwarding registry: each constant forwards to the + * layered {@code domain.semantic} code of its {@link net.openan.a2at.sdk.core.exception.ErrorCatalog} replacement. + */ +class NegotiationDeliveredResourceContractTest { + + private static final List TYPE_ORDER = + List.of(NegotiationType.INFORMATION, NegotiationType.TARGET, NegotiationType.FEASIBILITY); + + private static final List PERFORMATIVE_ORDER = + List.of(NegotiationPerformative.PROPOSE, NegotiationPerformative.ACCEPT); + + private static final List LANGUAGES = List.of("zh-CN", "en-US"); + + @Test + void everyPackagedTemplateIsByteIdenticalToTheDeliveredFile() throws IOException { + List mismatches = new ArrayList<>(); + int compared = 0; + for (NegotiationType type : TYPE_ORDER) { + for (NegotiationPerformative performative : PERFORMATIVE_ORDER) { + for (String language : LANGUAGES) { + String relativePath = String.join( + "/", + "templates", + "Negotiation-T", + type.typeSegment(), + NegotiationReference.uriSegmentOf(performative), + "v1", + language, + "template.md"); + byte[] packaged = readClasspathBytes("prompt_resources/" + relativePath); + byte[] delivered = + Files.readAllBytes(deliveredResourceRoot().resolve(relativePath)); + compared++; + if (!java.util.Arrays.equals(packaged, delivered)) { + mismatches.add(relativePath); + } + } + } + } + assertEquals(12, compared, "exactly the twelve negotiation templates must be compared"); + assertTrue( + mismatches.isEmpty(), "packaged templates must match the delivered files byte for byte: " + mismatches); + } + + @Test + void errorCodeConstantsStayExactlyThePinnedRegistry() { + java.util.Map expected = new java.util.LinkedHashMap<>(); + expected.put("SDK_INTERNAL_ERROR", "infra.internal_error"); + expected.put("INPUT_TEXT_TOO_LONG", "input.text_too_long"); + expected.put("PARAM_EXTRACTION_FAILED", "slot.not_provided"); + expected.put("TEMPLATE_NOT_FOUND", "template.not_found"); + expected.put("NEGOTIATION_CONTENT_EXTRACT_FAILED", "negotiation.content_extract_failed"); + expected.put("NEGOTIATION_SEMANTIC_REJECTED", "negotiation.semantic_rejected"); + expected.put("NEGOTIATION_RULE_VIOLATION", "negotiation.rule_violation"); + expected.put("NEGOTIATION_SLOT_MISSING", "negotiation.field_missing"); + expected.put("NEGOTIATION_INVALID_INPUT", "negotiation.invalid_input"); + expected.put("NEGOTIATION_LLM_INFRASTRUCTURE_ERROR", "llm.invocation_failed"); + expected.put("VALIDATION_INVALID_INPUT", "negotiation.invalid_input"); + expected.put("VALIDATION_RULE_VIOLATION", "negotiation.rule_violation"); + expected.put("VALIDATION_SEMANTIC_REJECTED", "negotiation.semantic_rejected"); + expected.put("VALIDATION_LLM_INFRASTRUCTURE_ERROR", "llm.invocation_failed"); + expected.put("VALIDATION_PROMPT_RESOURCE_NOT_FOUND", "template.not_found"); + expected.put("PROMPT_RESOURCE_LOAD_ERROR", "template.load_failed"); + expected.put("SLOT_SCHEMA_NOT_FOUND", "slot.schema_not_found"); + expected.put("LLM_INVOCATION_FAILED", "llm.invocation_failed"); + expected.put("RENDER_FAILED", "template.render_failed"); + expected.put("SLOT_VALIDATION_ERROR", "slot.rule_violation"); + expected.put("PROCESSED_PROMPT_PARSE_ERROR", "scenario.not_matched"); + + java.util.Map actual = new java.util.LinkedHashMap<>(); + for (java.lang.reflect.Field field : A2ATErrorCodes.class.getDeclaredFields()) { + if (field.getType() == String.class && java.lang.reflect.Modifier.isStatic(field.getModifiers())) { + field.setAccessible(true); + try { + actual.put(field.getName(), (String) field.get(null)); + } catch (IllegalAccessException exception) { + throw new IllegalStateException("Failed to read the constant " + field.getName(), exception); + } + } + } + + assertEquals( + expected, + actual, + "the deprecated forwarding registry is locked; adding or changing constants must update this" + + " test"); + assertTrue( + !actual.containsValue("negotiation_type_unrecognized"), + "the removed type-recognition code must not reappear"); + } + + private static byte[] readClasspathBytes(String classpathPath) throws IOException { + InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(classpathPath); + assertNotNull(stream, "the resource must exist on the test classpath: " + classpathPath); + try (stream) { + return stream.readAllBytes(); + } + } + + private static Path deliveredResourceRoot() { + return Path.of("..", "a2a-t-resources", "src", "main", "resources", "prompt_resources"); + } +} 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 new file mode 100644 index 00000000..c8cce0ef --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/NegotiationPackageStructureTest.java @@ -0,0 +1,150 @@ +package net.openan.a2at.sdk.negotiation.contract; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +/** + * Guards the package layout of the negotiation content layer. + * + *

The four content-layer packages carry exactly the pinned set of compilation units and no subpackages, so that the + * internal layering (models and vocabulary, resources, generation, validation) cannot drift silently. The pre-existing + * legacy packages of the old negotiation state machine are not pinned by this guard. + */ +class NegotiationPackageStructureTest { + + @Test + void contentPackageExposesExactlyThePinnedTypes() throws IOException { + assertEquals( + List.of( + "FeasibilityEndingContent.java", + "FeasibilityProposeContent.java", + "InformationEndingContent.java", + "InformationProposeContent.java", + "NegotiationAbortContent.java", + "NegotiationAbortData.java", + "NegotiationAction.java", + "NegotiationConclusion.java", + "NegotiationContent.java", + "NegotiationEndingContent.java", + "NegotiationEndingData.java", + "NegotiationGenerationException.java", + "NegotiationItem.java", + "NegotiationParamExtractionException.java", + "NegotiationProcessingException.java", + "NegotiationProposeContent.java", + "NegotiationProposeData.java", + "NegotiationType.java", + "TargetEndingContent.java", + "TargetProposeContent.java", + "Vocabulary.java", + "package-info.java"), + topLevelJavaFiles(negotiationRoot().resolve("content"))); + assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("content"))); + } + + @Test + void resourcesPackageExposesExactlyThePinnedTypes() throws IOException { + assertEquals( + List.of( + "DefaultNegotiationTemplateLoader.java", + "NegotiationReference.java", + "NegotiationTemplateLoader.java", + "package-info.java"), + topLevelJavaFiles(negotiationRoot().resolve("resources"))); + assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("resources"))); + } + + @Test + void generationPackageExposesExactlyThePinnedTypes() throws IOException { + assertEquals( + List.of( + "AbortGenerator.java", + "AbstractNegotiationGenerator.java", + "DefaultNegotiationContentExtractor.java", + "FeasibilityEndingGenerator.java", + "FeasibilityProposeGenerator.java", + "InformationEndingGenerator.java", + "InformationProposeGenerator.java", + "NegotiationContentExtractor.java", + "NegotiationContentService.java", + "NegotiationGenerationOrchestrator.java", + "NegotiationGenerationOrchestratorBuilder.java", + "NegotiationGenerator.java", + "NegotiationGeneratorRegistry.java", + "NegotiationItemFormatter.java", + "NegotiationJsonSchemaBuilder.java", + "NegotiationMessageBuilder.java", + "NegotiationPromptRenderer.java", + "NegotiationPromptResourceLoader.java", + "NegotiationRenderException.java", + "TargetEndingGenerator.java", + "TargetProposeGenerator.java", + "package-info.java"), + topLevelJavaFiles(negotiationRoot().resolve("generation"))); + assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("generation"))); + } + + @Test + void validationPackageExposesExactlyThePinnedTypes() throws IOException { + assertEquals( + List.of( + "DefaultNegotiationComplianceChecker.java", + "DefaultNegotiationSemanticValidator.java", + "NegotiationComplianceChecker.java", + "NegotiationRuleCheckResult.java", + "NegotiationRuleCheckerAdapter.java", + "NegotiationSemanticValidator.java", + "NegotiationValidationException.java", + "ParamExtractor.java", + "SemanticValidationResult.java", + "package-info.java"), + topLevelJavaFiles(negotiationRoot().resolve("validation"))); + assertEquals(List.of(), topLevelDirectories(negotiationRoot().resolve("validation"))); + } + + @Test + void contentLayerPackagesKeepNoUnexpectedSubpackages() { + Path root = negotiationRoot(); + List.of("content", "generation", "resources", "validation") + .forEach(packageName -> assertFalse( + hasSubdirectory(root.resolve(packageName)), + "the " + packageName + " package must not grow subpackages")); + } + + private static Path negotiationRoot() { + return Path.of("src", "main", "java", "net", "openan", "a2at", "sdk", "negotiation"); + } + + private static boolean hasSubdirectory(Path path) { + try (var files = Files.list(path)) { + return files.anyMatch(Files::isDirectory); + } catch (IOException exception) { + throw new IllegalStateException("Failed to list " + path, exception); + } + } + + private static List topLevelJavaFiles(Path path) throws IOException { + try (var files = Files.list(path)) { + return files.filter(file -> file.getFileName().toString().endsWith(".java")) + .map(file -> file.getFileName().toString()) + .sorted() + .collect(Collectors.toList()); + } + } + + private static List topLevelDirectories(Path path) throws IOException { + try (var files = Files.list(path)) { + return files.filter(Files::isDirectory) + .map(file -> file.getFileName().toString()) + .sorted() + .collect(Collectors.toList()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/SdkSourceCommentScanTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/SdkSourceCommentScanTest.java new file mode 100644 index 00000000..d954ee3b --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/contract/SdkSourceCommentScanTest.java @@ -0,0 +1,69 @@ +package net.openan.a2at.sdk.negotiation.contract; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; + +/** + * Scans the SDK main sources for references to internal design documents. + * + *

Comments and javadoc of the shipped sources must be self-contained English text: they must not reference the + * internal design specification, internal document paths or numbered spec sections. The scan walks the main source + * trees of the core, negotiation, client and server modules and fails with the offending file and line. + */ +class SdkSourceCommentScanTest { + + private static final List FORBIDDEN_PATTERNS = List.of( + Pattern.compile("设计规范"), + Pattern.compile("design spec"), + Pattern.compile("docs-local"), + Pattern.compile("docs/feat"), + Pattern.compile("§\\d")); + + private static final List SCANNED_MODULE_ROOTS = List.of( + "../a2a-t-core/src/main/java", + "../a2a-t-negotiation/src/main/java", + "../a2a-t-client/src/main/java", + "../a2a-t-server/src/main/java"); + + @Test + void mainSourcesNeverReferenceInternalDesignDocuments() throws IOException { + List violations = new ArrayList<>(); + for (String moduleRoot : SCANNED_MODULE_ROOTS) { + Path root = Path.of(moduleRoot); + assertTrue(Files.isDirectory(root), "the scanned module root must exist: " + moduleRoot); + try (Stream files = Files.walk(root)) { + files.filter(file -> file.toString().endsWith(".java")) + .forEach(file -> collectViolations(file, violations)); + } + } + assertTrue( + violations.isEmpty(), + "main sources must not reference internal design documents: " + String.join("; ", violations)); + } + + private static void collectViolations(Path file, List violations) { + List lines; + try { + lines = Files.readAllLines(file, StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new IllegalStateException("Failed to read " + file, exception); + } + for (int index = 0; index < lines.size(); index++) { + String line = lines.get(index); + for (Pattern pattern : FORBIDDEN_PATTERNS) { + if (pattern.matcher(line).find()) { + violations.add(file + ":" + (index + 1) + " matches " + pattern.pattern()); + } + } + } + } +} 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..5ed8aac8 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/AbortMessageGenerationTest.java @@ -0,0 +1,298 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import org.junit.jupiter.api.Test; + +/** + * Verifies abort message generation from typed data through the full orchestrator pipeline. + * + *

The abort message is type-independent: one common template, one content type carrying only the termination reason. + * The rendered message must keep the fixed Abort conclusion section, carry the negotiation context bullets and never + * leak an unreplaced placeholder. The from-data variant must stay deterministic — no LLM call. + */ +class AbortMessageGenerationTest { + + private static final String SESSION_ID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri ABORT_URI = StandardTemplates.NEGOTIATION_ABORT; + + private final CountingClient llm = new CountingClient(); + + private NegotiationGenerationOrchestrator orchestrator(String language) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .llmClient(llm) + .build(); + } + + @Test + void generatesAChineseAbortMessageFromDataWithoutAnyLlmCall() { + MetadataContent result = orchestrator("zh-CN") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + String text = result.promptText(); + assertFalse(text.contains("## 协商上下文"), "the context section must not be rendered"); + assertTrue(text.contains("## 协商结果\nAbort"), "the fixed Abort conclusion must be kept"); + assertTrue(text.contains("## 协商终止原因"), "the termination reason section must be rendered"); + assertTrue(text.contains("达到协商轮次上限,本次协商确认结束。")); + assertEquals( + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), result.negotiationContext()); + assertFalse(text.contains("{{"), "no unreplaced placeholder may remain"); + assertEquals(0, llm.calls, "the from-data variant must never call the LLM"); + } + + @Test + void generatesAnEnglishAbortMessageFromData() { + MetadataContent result = orchestrator("en-US") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 3, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent( + "Reached the negotiation round limit. This negotiation is confirmed and ended.")), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + String text = result.promptText(); + assertFalse(text.contains("## Negotiation Context"), "the context section must not be rendered"); + assertTrue(text.contains("## Negotiation Result\nAbort")); + assertTrue(text.contains("## Negotiation Termination Reason")); + assertTrue(text.contains("Reached the negotiation round limit.")); + assertEquals( + new NegotiationContext(SESSION_ID, 3, 5, NegotiationPerformative.ABORT), result.negotiationContext()); + assertFalse(text.contains("{{")); + assertEquals(0, llm.calls); + } + + @Test + void blankTerminationReasonFailsWithTheCodedContentInvalidFailureBeforeAnyLlmCall() { + NegotiationGenerationException failure = + assertThrows(NegotiationGenerationException.class, () -> orchestrator("zh-CN") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent(" ")), + ABORT_URI)); + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), failure.getCode()); + assertEquals("content.terminationReason", failure.getFacts().get("field")); + assertTrue( + failure.getMessage().contains("content.terminationReason"), + "message must point at the offending field"); + assertEquals(0, llm.calls); + } + + @Test + void abortGenerationRejectsTypedTemplateUris() { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> orchestrator("zh-CN") + .generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限。")), + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT)); + assertTrue(failure.getMessage().contains("abort")); + } + + @Test + void typedEndingGenerationRejectsTheAbortTemplateUri() { + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, () -> orchestrator("zh-CN") + .generateRejectFromData( + new net.openan.a2at.sdk.negotiation.content.NegotiationEndingData( + new NegotiationContext(SESSION_ID, 1, 5, NegotiationPerformative.REJECT), + new net.openan.a2at.sdk.negotiation.content.TargetEndingContent( + net.openan.a2at.sdk.negotiation.content.NegotiationConclusion.REJECT, + null, + "预算耗尽")), + ABORT_URI)); + assertTrue(failure.getMessage().contains("reject")); + } + + @Test + void singleQueryResolvesTheCommonAbortTemplateUri() { + assertTrue(new TemplateQueryService("zh-CN", "classpath", null) + .getPrompt(ABORT_URI) + .isPresent()); + assertTrue(new TemplateQueryService("en-US", "classpath", null) + .getPrompt(ABORT_URI) + .isPresent()); + } + + @Test + void serviceMirrorsTheAbortGenerationEntryPoint() { + NegotiationContentService service = + new NegotiationContentService(NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build()); + + MetadataContent result = service.generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 2, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("token 消耗超限,协商终止。")), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + assertTrue(result.promptText().contains("token 消耗超限")); + assertEquals(0, llm.calls); + } + + @Test + void generatesAnAbortMessageFromFreeTextThroughOneExtractionStep() { + ScriptedClient extractionLlm = new ScriptedClient("{\"termination_reason\":\"达到协商轮次上限,协商终止。\"}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(extractionLlm) + .build(); + + MetadataContent result = orchestrator.generateAbortFromText( + "协商已达到轮次上限,无法继续推进,本次协商终止。", + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), + ABORT_URI); + + assertEquals(ABORT_URI.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("达到协商轮次上限,协商终止。")); + assertFalse(result.promptText().contains("{{")); + assertEquals(1, extractionLlm.calls, "the from-text variant runs exactly one extraction step"); + } + + @Test + void peerValidatesAReceivedAbortMessageAndExtractsItsParameters() { + NegotiationGenerationOrchestrator agent = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + ScriptedClient peerLlm = + new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator peer = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(peerLlm) + .build(); + + MetadataContent abortMessage = agent.generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + ABORT_URI); + + FilledParamData parameters = peer.validateAbortPromptAndDataFilling( + abortMessage.promptText(), + new NegotiationContext(SESSION_ID, 5, 5, NegotiationPerformative.ABORT), + Map.of("type", "object"), + ABORT_URI); + + assertEquals(SESSION_ID, parameters.data().get("id")); + assertEquals(5, parameters.data().get("round")); + assertEquals(5, parameters.data().get("maxRounds")); + assertEquals(1, peerLlm.calls, "the validation pipeline runs exactly one semantic step"); + } + + @Test + void abortValidationFailsOnBeyondBudgetRoundsBeforeAnyLlmCall() { + NegotiationGenerationOrchestrator agent = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + ScriptedClient peerLlm = + new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":null,\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator peer = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(peerLlm) + .build(); + + MetadataContent beyondBudget = agent.generateAbortFromData( + new NegotiationAbortData( + new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("轮次预算耗尽。")), + ABORT_URI); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> peer.validateAbortPromptAndDataFilling( + beyondBudget.promptText(), + new NegotiationContext(SESSION_ID, 6, 5, NegotiationPerformative.ABORT), + Map.of("type", "object"), + ABORT_URI)); + assertEquals(ErrorCatalog.NEGOTIATION_RULE_VIOLATION.getCode(), failure.getCode()); + assertTrue( + failure.getErrors().stream().anyMatch(error -> "round".equals(error.slotName())), + "the beyond-budget round must be reported on the round slot"); + assertEquals(0, peerLlm.calls, "the rule gate must fail before any semantic LLM call"); + } + + @Test + void abortValidationRejectsTypedTemplateUris() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + IllegalArgumentException failure = assertThrows( + IllegalArgumentException.class, + () -> orchestrator.validateAbortPromptAndDataFilling( + "任意文本", null, Map.of("type", "object"), StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE)); + assertTrue(failure.getMessage().contains("abort")); + assertEquals(0, llm.calls); + } + + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new AssertionError("The from-data abort variant must never call the LLM"); + } + } + + private static final class ScriptedClient implements LLMClient { + + private final String payload; + + private final List>> recordedMessages = new ArrayList<>(); + + private int calls; + + private ScriptedClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + recordedMessages.add(List.copyOf(messages)); + return new LLMResponse(payload, "scripted-abort-model", Map.of(), Map.of()); + } + } +} 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 new file mode 100644 index 00000000..829c9b72 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConclusionLiteralAndExceptionSlotTest.java @@ -0,0 +1,197 @@ +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.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.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, NegotiationPerformative.ACCEPT); + + 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, NegotiationPerformative.REJECT); + + 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, NegotiationPerformative.ACCEPT), + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, summary)), + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT) + .promptText(); + String rejectText = orchestrator + .generateRejectFromData( + new NegotiationEndingData( + new NegotiationContext(UUID, 2, 5, NegotiationPerformative.REJECT), + 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, NegotiationPerformative.ACCEPT), + 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 new file mode 100644 index 00000000..86462a2c --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/ConditionalSectionRenderingTest.java @@ -0,0 +1,261 @@ +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.core.model.NegotiationPerformative; +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, 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)), + 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, NegotiationPerformative.PROPOSE), + 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")), + null)), + 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, NegotiationPerformative.PROPOSE), + new TargetProposeContent( + "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(); + + 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, NegotiationPerformative.PROPOSE), + new TargetProposeContent( + "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(); + + assertTrue(promptText.contains(alignmentTitle(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) { + NegotiationGenerationOrchestrator orchestrator = orchestrator(language); + + String withoutClarification = orchestrator + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new TargetProposeContent( + "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(); + assertFalse(withoutClarification.contains(clarificationTitle(language))); + + String withClarification = orchestrator + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new TargetProposeContent( + "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")), + null)), + 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"; + } + + 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/CustomRootPromptsIgnoredTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java new file mode 100644 index 00000000..cf606017 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/CustomRootPromptsIgnoredTest.java @@ -0,0 +1,133 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.Test; + +/** + * Locks the classpath-only loading of the negotiation LLM prompt resources. + * + *

The LLM prompt resources are deliberately not configurable: the system prompt sent to the LLM is always the + * built-in classpath prompt of the configured language. Both prompt-consuming steps are covered: the content extraction + * of the from-text generation and the semantic validation of the parameter extraction pipeline. + */ +class CustomRootPromptsIgnoredTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String EXTRACTION_RESPONSE = + "{\"items\":[{\"name\":\"节能区域\",\"value\":\"松山湖\"}],\"relationship\":null}"; + + private static final String SEMANTIC_RESPONSE = + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"; + + @Test + void contentExtractionKeepsUsingTheBuiltInSystemPrompt() { + RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + NegotiationContext context = new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE); + + MetadataContent result = orchestrator.generateProposeFromText("请提供节能区域。", context, INFORMATION_PROPOSE_URI); + + assertEquals(1, llm.callCount()); + String builtinSystemPrompt = + new NegotiationPromptResourceLoader().loadSystem("information_negotiation", "zh-CN"); + assertEquals( + builtinSystemPrompt, + llm.systemContentOfLastCall(), + "the system prompt sent to the LLM must be the built-in classpath prompt"); + assertTrue(llm.userContentOfLastCall().contains("请提供节能区域。"), "the user prompt must carry the input text"); + assertTrue(result.promptText().contains("节能区域")); + } + + @Test + void semanticValidationKeepsUsingTheBuiltInSystemPrompt() { + RecordingLlmClient llm = new RecordingLlmClient(EXTRACTION_RESPONSE, SEMANTIC_RESPONSE); + NegotiationGenerationOrchestrator orchestrator = orchestrator(llm); + MetadataContent message = orchestrator.generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message.promptText(), + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), + INFORMATION_PROPOSE_URI); + + assertEquals(2, llm.callCount()); + String builtinSystemPrompt = + new NegotiationPromptResourceLoader().loadSystem("negotiation_semantic_validation", "zh-CN"); + assertEquals( + builtinSystemPrompt, + llm.systemContentOfLastCall(), + "the semantic validation system prompt must be the built-in classpath prompt"); + assertEquals("松山湖", filled.data().get("region")); + } + + private NegotiationGenerationOrchestrator orchestrator(RecordingLlmClient llm) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + } + + private static final class RecordingLlmClient implements LLMClient { + + private final List payloads; + + private final ArrayList>> recordedMessages = new ArrayList<>(); + + private RecordingLlmClient(String... payloads) { + this.payloads = List.of(payloads); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + recordedMessages.add(messages); + String payload = payloads.get(Math.min(recordedMessages.size() - 1, payloads.size() - 1)); + return new LLMResponse( + payload, "recording-test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + + int callCount() { + return recordedMessages.size(); + } + + String systemContentOfLastCall() { + return roleContentOfLastCall("system"); + } + + String userContentOfLastCall() { + return roleContentOfLastCall("user"); + } + + private String roleContentOfLastCall(String role) { + List> messages = recordedMessages.get(recordedMessages.size() - 1); + return messages.stream() + .filter(message -> role.equals(message.get("role"))) + .map(message -> message.get("content")) + .findFirst() + .orElseThrow(); + } + } +} 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 new file mode 100644 index 00000000..d1a33880 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromDataProgrammingErrorMatrixTest.java @@ -0,0 +1,209 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.junit.jupiter.api.Test; + +/** + * Verifies the corpus-inexpressible rows of the programming-error matrix of the from-data generation (design §6 Q8). + * + *

The remaining rows of the matrix — null context, phase-conclusion mismatch, blank conditional fields of the ending + * family, empty evaluation/alternative lists and every template-URI phase violation — are absorbed by the + * {@code FD-PROG} batch of the test corpus ({@code negotiation-cases/from-data/programming-errors.json}), which asserts + * the same exception type, message fragment and zero-LLM guarantee per case. Only the rows the corpus cannot express + * stay here: null propose/ending data (a corpus record always carries {@code input.data}), a null conclusion and a + * missing feasibility action (the typed-input assembler rejects both as authoring defects), blank propose descriptions + * and ending summaries (the assembler demands non-blank text), and a template URI whose type contradicts the content + * type (the assembler derives the content type from the URI, so the contradiction cannot be authored). Every row fails + * with a standard {@link NullPointerException} (pure null arguments) or {@link IllegalArgumentException} (URI-shape and + * constructor-constraint violations) that is not part of the SDK business-failure hierarchy and carries an English + * message pointing at the offending input. Blank content fields are coded business failures carrying + * {@code negotiation.content_invalid} and are covered by a dedicated test below. No row ever reaches the LLM. + * Structural URI malformation is impossible by construction of {@link TemplateUri} and is therefore not a row. + */ +class FromDataProgrammingErrorMatrixTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final TemplateUri INFORMATION_ACCEPT_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT; + + private static final TemplateUri TARGET_PROPOSE_URI = StandardTemplates.TARGET_NEGOTIATION_PROPOSE; + + private static final TemplateUri FEASIBILITY_PROPOSE_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE; + + private static final TemplateUri FEASIBILITY_ACCEPT_URI = StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT; + + private static final TemplateUri TARGET_ACCEPT_URI = StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT; + + private final CountingClient llm = new CountingClient(); + + private final NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + @Test + void everyMatrixRowFailsWithAStandardJavaException() { + List matrix = matrix(); + + // The propose-versus-ending family mismatch of UT-GEN-002 is deliberately absent here: the typed data records + // make that mismatch a compile-time error at the facade, so it cannot occur at runtime. + assertEquals(5, matrix.size(), "exactly the corpus-inexpressible rows must stay (FD-PROG absorbs the rest)"); + for (MatrixRow row : matrix) { + RuntimeException failure = + assertThrows(row.expectedException(), () -> row.call().get(), "row must fail: " + row.label()); + assertFalse( + A2ATError.class.isInstance(failure), + "a programming error must not be part of the processing-error hierarchy: " + row.label()); + assertTrue( + failure.getMessage() != null && !failure.getMessage().isBlank(), + "failure message must be present: " + row.label()); + assertTrue( + isAsciiText(failure.getMessage()), + "failure message must be English (ASCII): " + failure.getMessage()); + assertTrue( + failure.getMessage().contains(row.expectedMessageFragment()), + "failure message must point at the problem of row " + row.label() + " but was: " + + failure.getMessage()); + } + assertEquals(0, llm.calls, "no matrix row may call the LLM"); + } + + /** + * Returns the corpus-inexpressible rows of the programming-error matrix of the from-data variants (design §6 Q8: + * FD-PROG-01..14 absorb the other fourteen rows). See the class javadoc for why each row below cannot be expressed + * as a corpus record. + */ + private List matrix() { + NegotiationContext context = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE); + NegotiationContext acceptContext = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.ACCEPT); + return List.of( + new MatrixRow( + "null propose data", + () -> orchestrator.generateProposeFromData(null, INFORMATION_PROPOSE_URI), + NullPointerException.class, + "Negotiation propose data must not be null."), + new MatrixRow( + "null ending data", + () -> orchestrator.generateAcceptFromData(null, INFORMATION_ACCEPT_URI), + NullPointerException.class, + "Negotiation ending data must not be null."), + new MatrixRow( + "accept method with null conclusion", + () -> orchestrator.generateAcceptFromData( + new NegotiationEndingData(acceptContext, new TargetEndingContent(null, "intent", null)), + TARGET_ACCEPT_URI), + NullPointerException.class, + "conclusion must not be null"), + new MatrixRow( + "feasibility propose without action", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, + new FeasibilityProposeContent( + "请评估。", null, List.of(new NegotiationItem("目标", "2Mbps")), null, null)), + FEASIBILITY_PROPOSE_URI), + NullPointerException.class, + "Feasibility negotiation action must not be null"), + new MatrixRow( + "template URI type contradicts the content type", + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, new TargetProposeContent("目标协商概述。", null, null, null, null)), + INFORMATION_PROPOSE_URI), + IllegalArgumentException.class, + "Negotiation type INFORMATION requires content of type InformationProposeContent")); + } + + @Test + void blankRequiredContentFieldsFailWithTheCodedContentInvalidFailure() { + NegotiationContext context = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE); + NegotiationContext acceptContext = new NegotiationContext(UUID, 2, 5, NegotiationPerformative.ACCEPT); + List blankFieldCalls = List.of( + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData(context, new TargetProposeContent(" ", null, null, null, null)), + TARGET_PROPOSE_URI), + () -> orchestrator.generateProposeFromData( + new NegotiationProposeData( + context, + new FeasibilityProposeContent( + " ", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("目标", "2Mbps")), + null, + null)), + FEASIBILITY_PROPOSE_URI), + () -> orchestrator.generateAcceptFromData( + new NegotiationEndingData( + acceptContext, new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, " ")), + FEASIBILITY_ACCEPT_URI)); + List expectedFields = List.of( + "content.targetNegotiationDescription", + "content.feasibilityNegotiationDescription", + "content.feasibilitySummary"); + + for (int index = 0; index < blankFieldCalls.size(); index++) { + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + blankFieldCalls.get(index)::run, + "blank content field " + expectedFields.get(index) + " must fail"); + assertEquals( + ErrorCatalog.NEGOTIATION_CONTENT_INVALID.getCode(), failure.getCode(), expectedFields.get(index)); + assertEquals(expectedFields.get(index), failure.getFacts().get("field")); + assertTrue(failure.getMessage().contains(expectedFields.get(index)), failure.getMessage()); + } + assertEquals(0, llm.calls, "no blank-content failure may call the LLM"); + } + + private static boolean isAsciiText(String message) { + return message.chars().allMatch(codePoint -> codePoint < 128); + } + + /** One row of the programming-error matrix: a failing call, the expected exception type and message fragment. */ + private record MatrixRow( + String label, + Supplier call, + Class expectedException, + String expectedMessageFragment) {} + + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new AssertionError("A from-data programming error must fail before any LLM call"); + } + } +} 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 new file mode 100644 index 00000000..2fcb017f --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/FromTextLlmPipelineTest.java @@ -0,0 +1,768 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.LlmConfig; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGoldenCases.GoldenCase; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.slf4j.LoggerFactory; + +/** + * Integration tests of the from-text generation pipeline against the real built-in resources. + * + *

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

Oversized inputs fail fast with the code {@code input.text_too_long} before any LLM call, inputs at exactly the + * limit pass the gate and reach the LLM, and the limit is configurable through the builder. + */ +class InputTextLengthLimitTest { + + private static final String ZH_CN = NegotiationGoldenCases.ZH_CN; + + private static final GoldenCase PROPOSE = GoldenCase.INFORMATION_PROPOSE; + + /** A text one character over the default limit must be rejected with the dedicated code before any LLM call. */ + @Test + void rejectsFromTextOverTheDefaultLimitBeforeAnyLlmCall() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1), + PROPOSE.context(), + PROPOSE.template())); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertTrue( + failure.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1)), + "the violation message must state the actual length but was: " + failure.getMessage()); + assertTrue( + failure.getMessage().contains(String.valueOf(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS)), + "the violation message must state the limit but was: " + failure.getMessage()); + assertEquals(0, llm.calls, "an oversized text must never reach the LLM"); + } + + /** A text at exactly the default limit passes the gate and reaches the extraction LLM call. */ + @Test + void acceptsFromTextAtExactlyTheDefaultLimit() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), PROPOSE.context(), PROPOSE.template())); + + assertNotEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertTrue(llm.calls >= 1, "a text at exactly the limit must reach the LLM"); + } + + /** An oversized prompt of the validation pipeline is rejected with the dedicated code before any LLM call. */ + @Test + void rejectsValidationPromptOverTheDefaultLimitBeforeAnyLlmCall() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS + 1), + PROPOSE.context(), + Map.of("type", "object"), + PROPOSE.template())); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertEquals(0, llm.calls, "an oversized prompt must never reach the LLM"); + } + + /** + * A validation prompt at exactly the default limit passes the gate and reaches the semantic validation LLM call. + */ + @Test + void acceptsValidationPromptAtExactlyTheDefaultLimit() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = defaultOrchestrator(llm); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + textOfLength(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), + PROPOSE.context(), + Map.of("type", "object"), + PROPOSE.template())); + + assertNotEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertTrue(llm.calls >= 1, "a prompt at exactly the limit must reach the LLM"); + } + + /** A configured limit below the default drives the rejection: a text one character over it fails fast. */ + @Test + void customLimitDrivesTheRejection() { + CountingClient llm = new CountingClient(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .llmClient(llm) + .maxTextChars(10) + .build(); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText("01234567890", PROPOSE.context(), PROPOSE.template())); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), failure.getCode()); + assertEquals(0, llm.calls); + } + + /** The builder rejects a non-positive limit as a programming error. */ + @Test + void builderRejectsNonPositiveLimit() { + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .maxTextChars(0) + .build()); + + assertTrue( + failure.getMessage().contains("max text chars"), + "the failure must point at the limit but was: " + failure.getMessage()); + } + + private static NegotiationGenerationOrchestrator defaultOrchestrator(LLMClient llmClient) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(ZH_CN) + .llmClient(llmClient) + .build(); + } + + private static String textOfLength(int length) { + return "x".repeat(length); + } + + /** + * LLM client stub recording every call and returning an empty payload, so the tests can assert both that a call + * happened and that the pipeline fails downstream of the length gate instead of inside it. + */ + private static final class CountingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + return new LLMResponse("", "counting-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java new file mode 100644 index 00000000..0beb3e76 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationContentExtractorTest.java @@ -0,0 +1,417 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import org.junit.jupiter.api.Test; + +class NegotiationContentExtractorTest { + + @Test + void extractsInformationProposeContent() { + RecordingClient llm = new RecordingClient( + "{\"items\":[{\"name\":\"故障发生时间\",\"value\":\"精确到分钟的时间点\"},{\"name\":\"受影响小区标识\",\"value\":null}]," + + "\"relationship\":\"故障发生时间与受影响小区标识需逐小区对应\"}"); + + NegotiationContent content = new DefaultNegotiationContentExtractor(llm) + .extract( + "请提供故障发生时间与受影响小区标识。", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN")); + + InformationProposeContent proposeContent = assertInstanceOf(InformationProposeContent.class, content); + assertEquals( + List.of(new NegotiationItem("故障发生时间", "精确到分钟的时间点"), new NegotiationItem("受影响小区标识", null)), + proposeContent.items()); + assertEquals("故障发生时间与受影响小区标识需逐小区对应", proposeContent.relationship()); + + assertEquals(2, llm.lastMessages.size()); + assertEquals("system", llm.lastMessages.get(0).get("role")); + assertEquals("user", llm.lastMessages.get(1).get("role")); + assertTrue(llm.lastMessages.get(1).get("content").contains("协商阶段:propose")); + assertTrue(llm.lastMessages.get(1).get("content").contains("请提供故障发生时间与受影响小区标识。")); + assertEquals( + new NegotiationJsonSchemaBuilder() + .buildExtractionSchema(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE), + llm.lastSchema); + } + + @Test + void extractsTargetProposeContent() { + NegotiationContent content = new DefaultNegotiationContentExtractor( + new RecordingClient("{\"target_negotiation_description\":\"请求将节能目标由30%调整为20%。\"," + + "\"intent_understanding\":[{\"name\":\"发起方理解\",\"value\":\"对方希望降低节能力度\"}]," + + "\"alignment_and_clarification\":null," + + "\"request_for_clarification\":[{\"name\":\"速率保障下限\",\"value\":null}]}")) + .extract("请求调整节能目标。", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "en-US")); + + TargetProposeContent proposeContent = assertInstanceOf(TargetProposeContent.class, content); + assertEquals("请求将节能目标由30%调整为20%。", proposeContent.targetNegotiationDescription()); + assertEquals(List.of(new NegotiationItem("发起方理解", "对方希望降低节能力度")), proposeContent.intentUnderstanding()); + assertNull(proposeContent.alignmentAndClarification()); + assertEquals(List.of(new NegotiationItem("速率保障下限", null)), proposeContent.requestForClarification()); + } + + @Test + void extractsFeasibilityProposeContentForBothActions() { + NegotiationContent request = new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"请评估该节能目标能否达成。\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + + "\"contents_to_evaluate\":[{\"name\":\"评估对象\",\"value\":\"停电8小时期间的速率保障\"}]," + + "\"infeasibility_details_and_proposal\":null}")) + .extract("请评估。", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN")); + + FeasibilityProposeContent requestContent = assertInstanceOf(FeasibilityProposeContent.class, request); + assertEquals(NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, requestContent.action()); + assertEquals(List.of(new NegotiationItem("评估对象", "停电8小时期间的速率保障")), requestContent.contentsToEvaluate()); + + NegotiationContent alternative = new DefaultNegotiationContentExtractor( + new RecordingClient( + "{\"feasibility_negotiation_description\":\"目标不可行,提出下调方案。\",\"action\":\"PROPOSE_ALTERNATIVE_ON_FAILURE\"," + + "\"contents_to_evaluate\":null," + + "\"infeasibility_details_and_proposal\":[{\"name\":\"替代提案\",\"value\":\"下调至2Mbps\"}]}")) + .extract("不可行。", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN")); + + FeasibilityProposeContent alternativeContent = assertInstanceOf(FeasibilityProposeContent.class, alternative); + assertEquals(NegotiationAction.PROPOSE_ALTERNATIVE_ON_FAILURE, alternativeContent.action()); + assertEquals( + List.of(new NegotiationItem("替代提案", "下调至2Mbps")), alternativeContent.infeasibilityDetailsAndProposal()); + } + + @Test + void extractsTargetProposeConfirmRequestContent() { + NegotiationContent content = new DefaultNegotiationContentExtractor( + new RecordingClient("{\"target_negotiation_description\":\"任务目标澄清完成,请答复<目标澄清后的确认请求>。\"," + + "\"intent_understanding\":null," + + "\"alignment_and_clarification\":null," + + "\"request_for_clarification\":null," + + "\"target_confirm_request\":\"目标已经澄清,是否同意按照此目标继续执行?\"}")) + .extract("目标已经澄清,请确认。", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "zh-CN")); + + TargetProposeContent proposeContent = assertInstanceOf(TargetProposeContent.class, content); + assertEquals("任务目标澄清完成,请答复<目标澄清后的确认请求>。", proposeContent.targetNegotiationDescription()); + assertEquals("目标已经澄清,是否同意按照此目标继续执行?", proposeContent.targetConfirmRequest()); + assertNull(proposeContent.intentUnderstanding()); + assertNull(proposeContent.alignmentAndClarification()); + assertNull(proposeContent.requestForClarification()); + } + + @Test + void extractsFeasibilityProposeConfirmRequestContent() { + NegotiationContent content = new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求>。\"," + + "\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + + "\"contents_to_evaluate\":null," + + "\"infeasibility_details_and_proposal\":null," + + "\"feasibility_confirm_request\":\"评估目标可行,是否同意按照此目标继续执行?\"}")) + .extract( + "评估目标可行,请确认。", + reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN")); + + FeasibilityProposeContent proposeContent = assertInstanceOf(FeasibilityProposeContent.class, content); + assertEquals(NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, proposeContent.action()); + assertEquals("评估目标可行,是否同意按照此目标继续执行?", proposeContent.feasibilityConfirmRequest()); + assertNull(proposeContent.contentsToEvaluate()); + assertNull(proposeContent.infeasibilityDetailsAndProposal()); + } + + @Test + void passesConfirmRequestWordingThroughLeniently() { + TargetProposeContent target = assertInstanceOf( + TargetProposeContent.class, + new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"target_negotiation_description\":\"描述\",\"intent_understanding\":null," + + "\"alignment_and_clarification\":null,\"request_for_clarification\":null," + + "\"target_confirm_request\":\"请确认按此目标推进\"}")) + .extract("文本", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "zh-CN"))); + assertEquals("请确认按此目标推进", target.targetConfirmRequest()); + + FeasibilityProposeContent feasibility = assertInstanceOf( + FeasibilityProposeContent.class, + new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"描述\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + + "\"contents_to_evaluate\":null,\"infeasibility_details_and_proposal\":null," + + "\"feasibility_confirm_request\":\"方案可行,望确认\"}")) + .extract( + "文本", + reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); + assertEquals("方案可行,望确认", feasibility.feasibilityConfirmRequest()); + } + + @Test + void extractsEndingContentForEveryTypeAndBothPhases() { + NegotiationContent infoAccept = new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"conclusion\":\"Accept\",\"items\":[{\"name\":\"故障发生时间\",\"value\":\"2026-08-19 10:30\"}]}")) + .extract("同意提供。", reference(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, "zh-CN")); + + InformationEndingContent infoEnding = assertInstanceOf(InformationEndingContent.class, infoAccept); + assertEquals(NegotiationConclusion.ACCEPT, infoEnding.conclusion()); + assertEquals(List.of(new NegotiationItem("故障发生时间", "2026-08-19 10:30")), infoEnding.items()); + + NegotiationContent targetReject = new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"conclusion\":\"Reject\",\"confirmed_intent\":null,\"failure_reason\":\"双方未达成一致。\"}")) + .extract("无法同意。", reference(NegotiationType.TARGET, NegotiationPerformative.REJECT, "zh-CN")); + + TargetEndingContent targetEnding = assertInstanceOf(TargetEndingContent.class, targetReject); + assertEquals(NegotiationConclusion.REJECT, targetEnding.conclusion()); + assertEquals("双方未达成一致。", targetEnding.failureReason()); + + NegotiationContent feasibilityAccept = new DefaultNegotiationContentExtractor( + new RecordingClient("{\"conclusion\":\"Accept\",\"feasibility_summary\":\"同意下调至2Mbps。\"}")) + .extract("同意。", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.ACCEPT, "zh-CN")); + + assertEquals( + "同意下调至2Mbps。", + assertInstanceOf( + net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent.class, + feasibilityAccept) + .feasibilitySummary()); + + assertEquals("accept", phaseTokenInUserPrompt(NegotiationPerformative.ACCEPT)); + assertEquals("reject", phaseTokenInUserPrompt(NegotiationPerformative.REJECT)); + assertEquals("propose", phaseTokenInUserPrompt(NegotiationPerformative.PROPOSE)); + } + + @Test + void mapsTransportFailuresToTheInvocationFailedCode() { + LLMClient failing = (messages, jsonSchema, temperature, maxTokens) -> { + throw new IllegalStateException("connection reset"); + }; + + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(failing) + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), exception.getCode()); + } + + @Test + void mapsMissingLlmClientToTheNotConfiguredCode() { + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(null) + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.LLM_NOT_CONFIGURED.getCode(), exception.getCode()); + } + + @Test + void mapsResponseContractViolationsAndShapeFailures() { + NegotiationGenerationException unparseable = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("这不是 JSON")) + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), unparseable.getCode()); + + NegotiationGenerationException wrongShape = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"items\":\"不是数组\"}")) + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_CONTENT_EXTRACT_FAILED.getCode(), wrongShape.getCode()); + assertEquals("items", wrongShape.getFacts().get("field")); + + NegotiationGenerationException emptyResponse = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient(" ")) + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), emptyResponse.getCode()); + } + + @Test + void mapsMissingRequiredFieldsToTheSlotMissingCode() { + NegotiationGenerationException missingItems = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"relationship\":null}")) + .extract( + "文本", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingItems.getCode()); + assertTrue(missingItems.getMessage().contains("items")); + + NegotiationGenerationException missingDescription = assertThrows( + NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"intent_understanding\":null,\"alignment_and_clarification\":null," + + "\"request_for_clarification\":null}")) + .extract("文本", reference(NegotiationType.TARGET, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingDescription.getCode()); + assertTrue(missingDescription.getMessage().contains("target_negotiation_description")); + + NegotiationGenerationException missingConclusion = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"items\":[]}")) + .extract( + "文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingConclusion.getCode()); + assertTrue(missingConclusion.getMessage().contains("conclusion")); + + NegotiationGenerationException missingConfirmedIntent = assertThrows( + NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"conclusion\":\"Accept\",\"confirmed_intent\":null,\"failure_reason\":null}")) + .extract("文本", reference(NegotiationType.TARGET, NegotiationPerformative.ACCEPT, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), missingConfirmedIntent.getCode()); + assertTrue(missingConfirmedIntent.getMessage().contains("confirmed_intent")); + } + + @Test + void mapsConclusionPhaseMismatchesToTheConclusionMismatchCode() { + NegotiationGenerationException rejectInAccept = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"conclusion\":\"Reject\",\"items\":[]}")) + .extract( + "文本", reference(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), rejectInAccept.getCode()); + assertEquals("Accept", rejectInAccept.getFacts().get("expected")); + assertEquals("Reject", rejectInAccept.getFacts().get("actual")); + + NegotiationGenerationException acceptInReject = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"conclusion\":\"Accept\",\"feasibility_summary\":\"同意。\"}")) + .extract( + "文本", reference(NegotiationType.FEASIBILITY, NegotiationPerformative.REJECT, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), acceptInReject.getCode()); + } + + @Test + void rejectsEmptyInformationEndingItemsBeforeTemplateRendering() { + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"conclusion\":\"Reject\",\"items\":[]}")) + .extract( + "拒绝,资源查询服务正在检修。", + reference(NegotiationType.INFORMATION, NegotiationPerformative.REJECT, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); + assertTrue(exception.getMessage().contains("result content")); + } + + @Test + void rejectsEmptyInformationProposeItemsBeforeTemplateRendering() { + NegotiationGenerationException exception = + assertThrows(NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor( + new RecordingClient("{\"items\":[]}")) + .extract( + "请补充缺失信息。", + reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_FIELD_MISSING.getCode(), exception.getCode()); + assertTrue(exception.getMessage().contains("requested items")); + } + + @Test + void mapsFeasibilityActionProblemsToTheInvalidInputCode() { + NegotiationGenerationException missingAction = assertThrows( + NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"描述\",\"contents_to_evaluate\":[]}")) + .extract( + "文本", + reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), missingAction.getCode()); + + NegotiationGenerationException emptyDrivenContent = assertThrows( + NegotiationGenerationException.class, () -> new DefaultNegotiationContentExtractor(new RecordingClient( + "{\"feasibility_negotiation_description\":\"描述\",\"action\":\"REQUEST_FEASIBILITY_EVALUATION\"," + + "\"contents_to_evaluate\":[],\"infeasibility_details_and_proposal\":null}")) + .extract( + "文本", + reference(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), emptyDrivenContent.getCode()); + } + + @Test + void rejectsBlankInputTextAndNullReference() { + DefaultNegotiationContentExtractor extractor = + new DefaultNegotiationContentExtractor(new RecordingClient("{}")); + + NegotiationGenerationException blank = assertThrows( + NegotiationGenerationException.class, + () -> extractor.extract( + " ", reference(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, "zh-CN"))); + + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), blank.getCode()); + + assertEquals( + "Negotiation reference must not be null.", + assertThrows(NullPointerException.class, () -> extractor.extract("文本", null)) + .getMessage()); + } + + private static NegotiationReference reference( + NegotiationType type, NegotiationPerformative performative, String language) { + return new NegotiationReference(type, performative, language); + } + + private static String phaseTokenInUserPrompt(NegotiationPerformative performative) { + RecordingClient client = new RecordingClient("{\"conclusion\":\"Accept\",\"items\":[]}"); + try { + new DefaultNegotiationContentExtractor(client) + .extract("文本", reference(NegotiationType.INFORMATION, performative, "zh-CN")); + } catch (NegotiationGenerationException expectedForReject) { + // The scripted conclusion only satisfies the accept phase; the messages are recorded either way. + } + String userPrompt = client.lastMessages.get(1).get("content").replace("\r\n", "\n"); + int start = userPrompt.indexOf("协商阶段:") + "协商阶段:".length(); + return userPrompt.substring(start, userPrompt.indexOf('\n', start)); + } + + private static final class RecordingClient implements LLMClient { + private final String payload; + + private List> lastMessages; + + private Map lastSchema; + + private RecordingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + this.lastMessages = messages; + this.lastSchema = jsonSchema; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } +} 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 new file mode 100644 index 00000000..ba3303e3 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorBuilderWiringTest.java @@ -0,0 +1,247 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMError; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.validation.NegotiationComplianceChecker; +import net.openan.a2at.sdk.negotiation.validation.NegotiationRuleCheckResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * Verifies the wiring defaults and injection seams of {@link NegotiationGenerationOrchestratorBuilder}. + * + *

The builder falls back to the module logger when no logger is injected, honors an injected compliance checker, + * propagates the attempt limit to both retry chains (content extraction and semantic validation), and exposes no + * negotiation-type recognizer injection point. + */ +class NegotiationGenerationOrchestratorBuilderWiringTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private Logger rootLogger; + + private ListAppender appender; + + @BeforeEach + void attachAppender() { + rootLogger = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + appender = new ListAppender<>(); + appender.start(); + rootLogger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + rootLogger.detachAppender(appender); + appender.stop(); + } + + @Test + void absentLoggerFallsBackToTheModuleLogger() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .build(); + + MetadataContent result = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + assertFalse(result.promptText().isBlank()); + ILoggingEvent completed = appender.list.stream() + .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) + .findFirst() + .orElseThrow(); + assertEquals(NegotiationGenerationOrchestrator.class.getName(), completed.getLoggerName()); + assertEquals(Level.INFO, completed.getLevel()); + } + + @Test + void injectedLoggerReceivesThePipelineEvents() { + Logger customLogger = (Logger) LoggerFactory.getLogger("negotiation.wiring.custom-logger"); + ListAppender customAppender = new ListAppender<>(); + customAppender.start(); + customLogger.addAppender(customAppender); + try { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .logger(customLogger) + .build(); + + orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + ILoggingEvent completed = customAppender.list.stream() + .filter(event -> event.getFormattedMessage().startsWith("negotiation_generation_completed")) + .findFirst() + .orElseThrow(); + assertEquals("negotiation.wiring.custom-logger", completed.getLoggerName()); + } finally { + customLogger.detachAppender(customAppender); + customAppender.stop(); + } + } + + @Test + void injectedComplianceCheckerIsUsedByTheValidationPipeline() { + RecordingComplianceChecker checker = new RecordingComplianceChecker(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(new ScriptedClient( + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}")) + .complianceChecker(checker) + .build(); + + orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI); + + assertTrue(checker.calls.get() >= 1, "the injected compliance checker must be used"); + } + + @Test + void nullTemplateContentFromCustomLoaderIsReportedAsTemplateNotFoundNotNpe() { + NegotiationTemplateLoader nullContentLoader = + reference -> new PromptTemplate(reference.templateUri(), "", null, PromptTemplate.SOURCE_CLASSPATH); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(nullContentLoader) + .build(); + + NegotiationParamExtractionException exception = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), exception.getCode()); + } + + @Test + void maxAttemptsReachesBothRetryChains() { + CountingFailingClient generationClient = new CountingFailingClient(); + NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(generationClient) + .maxAttempts(2) + .build(); + NegotiationGenerationException generationFailure = org.junit.jupiter.api.Assertions.assertThrows( + NegotiationGenerationException.class, + () -> generationOrchestrator.generateProposeFromText( + "请提供节能区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE_URI)); + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), generationFailure.getCode()); + assertEquals(2, generationClient.calls.get(), "generation chain must retry up to the limit"); + + CountingFailingClient semanticClient = new CountingFailingClient(); + NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(semanticClient) + .maxAttempts(2) + .build(); + org.junit.jupiter.api.Assertions.assertThrows( + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException.class, + () -> validationOrchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE_URI)); + assertEquals(2, semanticClient.calls.get(), "semantic validation chain must retry up to the limit"); + } + + @Test + void builderExposesNoTypeRecognizerInjectionPoint() { + for (Method method : NegotiationGenerationOrchestratorBuilder.class.getDeclaredMethods()) { + String name = method.getName().toLowerCase(Locale.ROOT); + assertFalse( + name.contains("recogn"), + "the builder must not expose a type recognizer injection point but declares " + method.getName()); + } + } + + private static final class RecordingComplianceChecker implements NegotiationComplianceChecker { + + private final AtomicInteger calls = new AtomicInteger(); + + @Override + public NegotiationRuleCheckResult check(net.openan.a2at.sdk.core.model.NegotiationContext context) { + calls.incrementAndGet(); + return new NegotiationRuleCheckResult(true, List.of()); + } + } + + private static final class ScriptedClient implements LLMClient { + + private final String payload; + + private ScriptedClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class CountingFailingClient implements LLMClient { + + private final AtomicInteger calls = new AtomicInteger(); + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls.incrementAndGet(); + throw new LLMError("LLM endpoint unavailable."); + } + } +} 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 new file mode 100644 index 00000000..8544d2e9 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGenerationOrchestratorTest.java @@ -0,0 +1,359 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; +import org.junit.jupiter.api.Test; + +class NegotiationGenerationOrchestratorTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + @Test + void generatesInformationProposeFromDataInChinese() { + MetadataContent result = zhOrchestrator() + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertEquals(NegotiationHandler.NEGOTIATION_T_URI, result.extensionUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); + assertTrue(result.promptText().contains("所需信息项")); + + Map metadata = result.buildMetadataContent(); + assertEquals(3, metadata.size()); + assertEquals(result.promptText(), metadata.get(NegotiationHandler.NEGOTIATION_T_URI)); + assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); + @SuppressWarnings("unchecked") + Map nestedContext = + (Map) metadata.get(MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY); + assertEquals(UUID, nestedContext.get("id")); + assertEquals(1, nestedContext.get("round")); + assertEquals(5, nestedContext.get("maxRounds")); + assertEquals("PROPOSE", nestedContext.get("performative")); + } + + @Test + void generatesInformationProposeFromDataInEnglish() { + MetadataContent result = orchestrator("en-US") + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent( + List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); + assertEquals(new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); + assertTrue(result.promptText().contains("Required Information Items")); + } + + @Test + void generatesProposeFromTextWithScriptedExtraction() { + ScriptedClient llm = + new ScriptedClient("{\"items\":[{\"name\":\"故障发生时间\",\"value\":\"精确到分钟的时间点\"}],\"relationship\":null}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + MetadataContent result = orchestrator.generateProposeFromText( + "请提供故障发生时间。", new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE); + + assertEquals(1, llm.calls); + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertEquals(2, result.negotiationContext().round()); + assertTrue(result.promptText().contains("故障发生时间")); + } + + @Test + void validatesAndFillsProposeDataWithScriptedSemanticResponse() { + ScriptedClient llm = + new ScriptedClient("{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[]," + + "\"params\":{\"region\":\"松山湖\"}}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + MetadataContent message = orchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE); + + FilledParamData filled = orchestrator.validateProposePromptAndDataFilling( + message.promptText(), + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), + INFORMATION_PROPOSE); + + assertEquals(1, llm.calls); + assertEquals(UUID, filled.data().get("id")); + assertEquals(1, filled.data().get("round")); + assertEquals(5, filled.data().get("maxRounds")); + assertEquals("松山湖", filled.data().get("region")); + } + + @Test + void listsAllSevenTemplatesPerLanguage() { + assertEquals(7, negotiationPrompts("zh-CN").size()); + assertEquals(7, negotiationPrompts("en-US").size()); + } + + @Test + void queriesSingleTemplateByUri() { + assertTrue(queryService("zh-CN").getPrompt(INFORMATION_PROPOSE).isPresent()); + assertTrue(queryService("zh-CN") + .getPrompt(StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT) + .isPresent()); + assertFalse(queryService("zh-CN") + .getPrompt(TemplateUri.of("Negotiation-T", "unknown-negotiation", "propose")) + .isPresent()); + } + + @Test + void retriesContentExtractionUntilItSucceeds() { + ScriptedClient llm = new ScriptedClient("", "", "{\"items\":[{\"name\":\"区域\",\"value\":\"松山湖\"}]}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(3) + .build(); + + MetadataContent result = orchestrator.generateProposeFromText( + "请提供区域。", new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), INFORMATION_PROPOSE); + + assertEquals(3, llm.calls); + assertFalse(result.promptText().isBlank()); + } + + @Test + void rethrowsOriginalCodeWhenRetriesAreExhausted() { + FailingClient llm = new FailingClient(); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(2) + .build(); + + NegotiationGenerationException failure = assertThrows( + NegotiationGenerationException.class, + () -> orchestrator.generateProposeFromText( + "请提供区域。", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + INFORMATION_PROPOSE)); + + assertEquals(2, llm.calls); + assertEquals(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), failure.getCode()); + } + + @Test + void reportsMissingTemplateAsTemplateNotFoundOnBothPipelines() { + NegotiationGenerationOrchestrator generationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(missingTemplateLoader()) + .build(); + + NegotiationGenerationException generationFailure = assertThrows( + NegotiationGenerationException.class, + () -> generationOrchestrator.generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + INFORMATION_PROPOSE)); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), generationFailure.getCode()); + + NegotiationGenerationOrchestrator validationOrchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .semanticValidator((prompt, callerSchema, reference, templateContent) -> { + throw new ResourceNotFoundException( + "Semantic validation prompt does not exist.", "prompt_resources/prompts"); + }) + .build(); + + NegotiationParamExtractionException extractionFailure = assertThrows( + NegotiationParamExtractionException.class, + () -> validationOrchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE)); + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), extractionFailure.getCode()); + } + + @Test + void validationTemplateLoadFailurePreservesTheOriginalCause() { + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .templateLoader(missingTemplateLoader()) + .build(); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "## 所需信息项\n1. 区域\n", + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + Map.of("type", "object"), + INFORMATION_PROPOSE)); + + assertEquals(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), failure.getCode()); + assertTrue( + failure.getCause() instanceof ContentValidationException, + "the template-load failure must preserve the mapped pipeline failure as cause for debugging"); + assertTrue( + failure.getCause().getCause() instanceof ResourceNotFoundException, + "the original resource failure stays reachable through the cause chain"); + } + + @Test + void rejectsNonNegotiationPromptInParameterExtraction() { + ScriptedClient llm = new ScriptedClient( + "{\"semantic_verdict\":true,\"negotiation_type\":\"information\",\"errors\":[],\"params\":{}}"); + NegotiationGenerationOrchestrator orchestrator = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .build(); + + NegotiationParamExtractionException failure = assertThrows( + NegotiationParamExtractionException.class, + () -> orchestrator.validateProposePromptAndDataFilling( + "plain text without any negotiation section", + null, + Map.of("type", "object"), + INFORMATION_PROPOSE)); + + assertEquals(ErrorCatalog.NEGOTIATION_INVALID_INPUT.getCode(), failure.getCode()); + assertEquals(0, llm.calls); + } + + @Test + void rejectsTemplateUriThatDoesNotAddressTheExpectedPerformative() { + IllegalArgumentException performativeMismatchFailure = + assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT)); + assertTrue(performativeMismatchFailure + .getMessage() + .contains("Template URI does not address a negotiation template of the expected performative" + + " PROPOSE (propose)")); + + IllegalArgumentException wrongExtensionFailure = + assertThrows(IllegalArgumentException.class, () -> zhOrchestrator() + .generateProposeFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("区域", "松山湖")), null)), + StandardTemplates.ENERGY_SAVING)); + assertTrue(wrongExtensionFailure + .getMessage() + .contains("Template URI does not address a negotiation template of the expected performative" + + " PROPOSE (propose)")); + } + + private static NegotiationGenerationOrchestrator zhOrchestrator() { + return orchestrator("zh-CN"); + } + + private static NegotiationGenerationOrchestrator orchestrator(String language) { + return NegotiationGenerationOrchestratorBuilder.builder() + .language(language) + .build(); + } + + private static TemplateQueryService queryService(String language) { + return new TemplateQueryService(language, "classpath", null); + } + + private static List negotiationPrompts(String language) { + return queryService(language).getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + + private static NegotiationTemplateLoader missingTemplateLoader() { + return new NegotiationTemplateLoader() { + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + }; + } + + private static final class ScriptedClient implements LLMClient { + + private final List payloads; + + private int calls; + + private ScriptedClient(String... payloads) { + this.payloads = List.of(payloads); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + String payload = payloads.get(Math.min(calls, payloads.size() - 1)); + calls++; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class FailingClient implements LLMClient { + + private int calls; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + calls++; + throw new IllegalStateException("LLM endpoint unavailable."); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java new file mode 100644 index 00000000..e4386890 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorRegistryTest.java @@ -0,0 +1,196 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import org.junit.jupiter.api.Test; + +class NegotiationGeneratorRegistryTest { + + private final NegotiationGeneratorRegistry registry = new NegotiationGeneratorRegistry(); + + private NegotiationGenerator resolve( + NegotiationType type, NegotiationPerformative phase, NegotiationContent content) { + return registry.resolve(type, phase, content, "zh-CN"); + } + + @Test + void dispatchesEveryTypeAndPhaseCombination() { + assertInstanceOf( + InformationProposeGenerator.class, + resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.PROPOSE, + new InformationProposeContent(List.of(), null))); + assertInstanceOf( + TargetProposeGenerator.class, + resolve( + NegotiationType.TARGET, + NegotiationPerformative.PROPOSE, + new TargetProposeContent("描述", null, null, null, null))); + assertInstanceOf( + FeasibilityProposeGenerator.class, + resolve( + NegotiationType.FEASIBILITY, + NegotiationPerformative.PROPOSE, + new FeasibilityProposeContent( + "描述", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("名称", "值")), + null, + null))); + assertInstanceOf( + InformationEndingGenerator.class, + resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + assertInstanceOf( + InformationEndingGenerator.class, + resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.REJECT, + new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); + assertInstanceOf( + TargetEndingGenerator.class, + resolve( + NegotiationType.TARGET, + NegotiationPerformative.ACCEPT, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); + assertInstanceOf( + TargetEndingGenerator.class, + resolve( + NegotiationType.TARGET, + NegotiationPerformative.REJECT, + new TargetEndingContent(NegotiationConclusion.REJECT, null, "失败原因"))); + assertInstanceOf( + FeasibilityEndingGenerator.class, + resolve( + NegotiationType.FEASIBILITY, + NegotiationPerformative.ACCEPT, + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "评估结论"))); + assertInstanceOf( + FeasibilityEndingGenerator.class, + resolve( + NegotiationType.FEASIBILITY, + NegotiationPerformative.REJECT, + new FeasibilityEndingContent(NegotiationConclusion.REJECT, "评估结论"))); + } + + @Test + void rejectsProposeContentInTerminalPhaseAndEndingContentInProposePhase() { + IllegalArgumentException proposeInEnding = assertThrows( + IllegalArgumentException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationProposeContent(List.of(), null))); + assertTrue(proposeInEnding + .getMessage() + .contains("ACCEPT phase requires ending content but received propose content of type" + + " InformationProposeContent")); + + IllegalArgumentException endingInPropose = assertThrows( + IllegalArgumentException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.PROPOSE, + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + assertTrue(endingInPropose + .getMessage() + .contains("PROPOSE phase requires propose content but received ending content of type" + + " InformationEndingContent")); + } + + @Test + void rejectsContentRuntimeTypeNotMatchingTheNegotiationType() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.PROPOSE, + new TargetProposeContent("描述", null, null, null, null))); + + assertTrue(exception + .getMessage() + .contains("Negotiation type INFORMATION requires content of type InformationProposeContent but received" + + " TargetProposeContent")); + + assertThrows( + IllegalArgumentException.class, + () -> resolve( + NegotiationType.TARGET, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(NegotiationConclusion.ACCEPT, List.of()))); + } + + @Test + void rejectsEndingConclusionNotMatchingThePhase() { + NegotiationGenerationException exception = assertThrows( + NegotiationGenerationException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(NegotiationConclusion.REJECT, List.of()))); + + assertEquals(ErrorCatalog.NEGOTIATION_CONCLUSION_MISMATCH.getCode(), exception.getCode()); + assertEquals("Accept", exception.getFacts().get("expected")); + assertEquals("Reject", exception.getFacts().get("actual")); + + assertThrows( + NegotiationGenerationException.class, + () -> resolve( + NegotiationType.TARGET, + NegotiationPerformative.REJECT, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "确认的意图", null))); + } + + @Test + void rejectsEndingContentWithoutConclusion() { + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> resolve( + NegotiationType.INFORMATION, + NegotiationPerformative.ACCEPT, + new InformationEndingContent(null, List.of()))); + + assertTrue(exception.getMessage().contains("conclusion must not be null")); + } + + @Test + void rejectsNullArguments() { + NegotiationContent content = new InformationProposeContent(List.of(), null); + + assertEquals( + "Negotiation type must not be null for the PROPOSE phase.", + assertThrows(NullPointerException.class, () -> resolve(null, NegotiationPerformative.PROPOSE, content)) + .getMessage()); + assertEquals( + "Negotiation phase must not be null.", + assertThrows(NullPointerException.class, () -> resolve(NegotiationType.INFORMATION, null, content)) + .getMessage()); + assertEquals( + "Negotiation content must not be null.", + assertThrows( + NullPointerException.class, + () -> resolve(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE, null)) + .getMessage()); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java new file mode 100644 index 00000000..3ce57491 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGeneratorsTest.java @@ -0,0 +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"); + } +} 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 new file mode 100644 index 00000000..dc75c448 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationGoldenCases.java @@ -0,0 +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 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/NegotiationItemFormatterTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatterTest.java new file mode 100644 index 00000000..3bf960fb --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationItemFormatterTest.java @@ -0,0 +1,45 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import org.junit.jupiter.api.Test; + +class NegotiationItemFormatterTest { + + private final NegotiationItemFormatter formatter = new NegotiationItemFormatter(); + + @Test + void formatsNumberedLinesWithSuppliedColonPunctuation() { + List items = + List.of(new NegotiationItem("节能区域信息", "松山湖"), new NegotiationItem("节能速率保障目标", "20Mbps")); + + assertEquals("1. 节能区域信息:松山湖\n2. 节能速率保障目标:20Mbps", formatter.format(items, ":")); + assertEquals( + "1. Energy-saving area information: Songshan Lake\n2. Rate guarantee target: 20 Mbps", + formatter.format( + List.of( + new NegotiationItem("Energy-saving area information", "Songshan Lake"), + new NegotiationItem("Rate guarantee target", "20 Mbps")), + ": ")); + } + + @Test + void omitsColonAndValueWhenValueIsNullOrBlank() { + List items = List.of(new NegotiationItem("名称", null), new NegotiationItem("另一个名称", " ")); + + assertEquals("1. 名称\n2. 另一个名称", formatter.format(items, ":")); + } + + @Test + void rendersEmptyStringForNullOrEmptyList() { + assertEquals("", formatter.format(null, ":")); + assertEquals("", formatter.format(List.of(), ":")); + } + + @Test + void toleratesNullColonPunctuation() { + assertEquals("1. 名称值", formatter.format(List.of(new NegotiationItem("名称", "值")), null)); + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java new file mode 100644 index 00000000..89040c6f --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationJsonSchemaBuilderTest.java @@ -0,0 +1,134 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.NegotiationType; +import org.junit.jupiter.api.Test; + +class NegotiationJsonSchemaBuilderTest { + + private final NegotiationJsonSchemaBuilder builder = new NegotiationJsonSchemaBuilder(); + + @Test + void informationProposeSchemaUsesSnakeCasePropertiesAndNullableRelationship() { + Map schema = + builder.buildExtractionSchema(NegotiationType.INFORMATION, NegotiationPerformative.PROPOSE); + + assertEquals("object", schema.get("type")); + Map properties = (Map) schema.get("properties"); + assertEquals(List.of("items", "relationship"), List.copyOf(properties.keySet())); + assertEquals(List.of("items"), schema.get("required")); + + Map relationship = (Map) properties.get("relationship"); + assertEquals(List.of("string", "null"), relationship.get("type")); + assertFalse(relationship.containsKey("enum")); + + Map items = (Map) properties.get("items"); + assertEquals("array", items.get("type")); + Map item = (Map) items.get("items"); + assertEquals(List.of("name", "value"), List.copyOf(((Map) item.get("properties")).keySet())); + assertEquals(List.of("name", "value"), item.get("required")); + } + + @Test + void informationEndingSchemaRequiresConclusionAndItems() { + Map schema = + builder.buildExtractionSchema(NegotiationType.INFORMATION, NegotiationPerformative.ACCEPT); + + Map properties = (Map) schema.get("properties"); + assertEquals(List.of("conclusion", "items"), List.copyOf(properties.keySet())); + assertEquals(List.of("conclusion", "items"), schema.get("required")); + assertEquals(List.of("Accept", "Reject"), ((Map) properties.get("conclusion")).get("enum")); + assertEquals(1, ((Map) properties.get("items")).get("minItems")); + } + + @Test + void acceptAndRejectShareTheTerminalSchema() { + assertEquals( + builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPerformative.ACCEPT), + builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPerformative.REJECT)); + } + + @Test + void targetProposeSchemaUsesSnakeCaseProperties() { + Map schema = builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPerformative.PROPOSE); + + Map properties = (Map) schema.get("properties"); + assertEquals( + List.of( + "target_negotiation_description", + "intent_understanding", + "alignment_and_clarification", + "request_for_clarification", + "target_confirm_request"), + List.copyOf(properties.keySet())); + assertEquals(List.of("target_negotiation_description"), schema.get("required")); + assertEquals(List.of("array", "null"), ((Map) properties.get("intent_understanding")).get("type")); + assertEquals(List.of("string", "null"), ((Map) properties.get("target_confirm_request")).get("type")); + } + + @Test + void targetEndingSchemaCarriesNullableResultFields() { + Map schema = builder.buildExtractionSchema(NegotiationType.TARGET, NegotiationPerformative.REJECT); + + Map properties = (Map) schema.get("properties"); + assertEquals(List.of("conclusion", "confirmed_intent", "failure_reason"), List.copyOf(properties.keySet())); + assertEquals(List.of("conclusion"), schema.get("required")); + assertEquals(List.of("string", "null"), ((Map) properties.get("confirmed_intent")).get("type")); + assertEquals(List.of("string", "null"), ((Map) properties.get("failure_reason")).get("type")); + } + + @Test + void feasibilityProposeSchemaRequiresTheActionEnum() { + Map schema = + builder.buildExtractionSchema(NegotiationType.FEASIBILITY, NegotiationPerformative.PROPOSE); + + Map properties = (Map) schema.get("properties"); + assertEquals( + List.of( + "feasibility_negotiation_description", + "action", + "contents_to_evaluate", + "infeasibility_details_and_proposal", + "feasibility_confirm_request"), + List.copyOf(properties.keySet())); + assertEquals(List.of("feasibility_negotiation_description", "action"), schema.get("required")); + Map action = (Map) properties.get("action"); + assertEquals(List.of("REQUEST_FEASIBILITY_EVALUATION", "PROPOSE_ALTERNATIVE_ON_FAILURE"), action.get("enum")); + assertEquals(List.of("string", "null"), ((Map) properties.get("feasibility_confirm_request")).get("type")); + } + + @Test + void feasibilityEndingSchemaRequiresTheSummary() { + Map schema = + builder.buildExtractionSchema(NegotiationType.FEASIBILITY, NegotiationPerformative.ACCEPT); + + Map properties = (Map) schema.get("properties"); + assertEquals(List.of("conclusion", "feasibility_summary"), List.copyOf(properties.keySet())); + assertEquals(List.of("conclusion", "feasibility_summary"), schema.get("required")); + } + + @Test + void rejectsNullTypeAndPhase() { + assertEquals( + "Negotiation type must not be null for the PROPOSE phase.", + assertThrows( + NullPointerException.class, + () -> builder.buildExtractionSchema(null, NegotiationPerformative.PROPOSE)) + .getMessage()); + assertEquals( + "Negotiation phase must not be null.", + assertThrows( + NullPointerException.class, + () -> builder.buildExtractionSchema(NegotiationType.INFORMATION, null)) + .getMessage()); + } + +} 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..dfaddfb3 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererConcurrencyTest.java @@ -0,0 +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(); + } + } +} diff --git a/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererTest.java b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererTest.java new file mode 100644 index 00000000..5fd8fa32 --- /dev/null +++ b/a2a-t-negotiation/src/test/java/net/openan/a2at/sdk/negotiation/generation/NegotiationPromptRendererTest.java @@ -0,0 +1,216 @@ +package net.openan.a2at.sdk.negotiation.generation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class NegotiationPromptRendererTest { + + private final NegotiationPromptRenderer renderer = new NegotiationPromptRenderer(); + + private static final String ZH_TEMPLATE = + """ + + + ## 协商上下文 + {{协商上下文}}(必填) + 要求: + 必须包含 id、round 与 maxRounds。 + + ## 信息协商 + 请根据<所需信息项>补充相关内容。 + + ## 所需信息项 + {{所需信息项}}(必填) + 要求: + 提供每个缺失项的名称。 + """; + + private static final String EN_TEMPLATE = + """ + + + ## Negotiation Context + {{Negotiation Context}} (required) + Requirements: + Every negotiation must include id, round and maxRounds. + + ## Required Information Items + {{Required Information Items}} (required) + Requirements: + Provide the name of each missing item. + """; + + @Test + void rendersSlotSectionsDroppingRequirementsAndStaticSections() { + Map slots = new LinkedHashMap<>(); + slots.put("协商上下文", "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n- round: 1\n- maxRounds: 5"); + slots.put("所需信息项", "1. 节能区域信息:松山湖"); + + String rendered = renderer.render(ZH_TEMPLATE, slots); + + assertEquals( + "## 协商上下文\n" + + "- id: 3dbc13b5-bd57-4c2b-b503-24e381b6c8d3\n" + + "- round: 1\n" + + "- maxRounds: 5\n" + + "\n" + + "## 信息协商\n" + + "请根据<所需信息项>补充相关内容。\n" + + "\n" + + "## 所需信息项\n" + + "1. 节能区域信息:松山湖", + rendered); + } + + @Test + void dropsWholeSlotSectionWhenValueIsMissingNullOrBlank() { + Map slots = new LinkedHashMap<>(); + slots.put("协商上下文", "- id: id-1\n- round: 1\n- maxRounds: 5"); + slots.put("所需信息项", null); + + String renderedWithNull = renderer.render(ZH_TEMPLATE, slots); + + assertEquals( + "## 协商上下文\n- id: id-1\n- round: 1\n- maxRounds: 5\n\n## 信息协商\n请根据<所需信息项>补充相关内容。", renderedWithNull); + + slots.put("所需信息项", " "); + String renderedWithBlank = renderer.render(ZH_TEMPLATE, slots); + + assertEquals(renderedWithNull, renderedWithBlank); + } + + @Test + void dropsLeadingHtmlCommentAndPreambleBeforeFirstSection() { + String rendered = renderer.render(ZH_TEMPLATE, Map.of("协商上下文", "- id: id-1", "所需信息项", "1. 名称")); + + assertFalse(rendered.contains(""; + + private TemplateDescriptions() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** + * Extracts the template description from a leading HTML comment. + * + * @param content full template text + * @return stripped comment text of the first line when it is an HTML comment, otherwise an empty string + */ + public static String extract(String content) { + int firstLineBreak = content.indexOf('\n'); + String firstLine = firstLineBreak < 0 ? content : content.substring(0, firstLineBreak); + firstLine = firstLine.strip(); + if (firstLine.startsWith(COMMENT_OPEN) + && firstLine.endsWith(COMMENT_CLOSE) + && firstLine.length() >= COMMENT_OPEN.length() + COMMENT_CLOSE.length()) { + return firstLine + .substring(COMMENT_OPEN.length(), firstLine.length() - COMMENT_CLOSE.length()) + .strip(); + } + return ""; + } +} diff --git a/a2a-t-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 new file mode 100644 index 00000000..0e955f61 --- /dev/null +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryService.java @@ -0,0 +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; + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/BuiltinFallbackWarnings.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/BuiltinFallbackWarnings.java new file mode 100644 index 00000000..9ff53f3d --- /dev/null +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/BuiltinFallbackWarnings.java @@ -0,0 +1,31 @@ +package net.openan.a2at.sdk.prompt.resources.loader; + +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Emits the one-time warning for a built-in classpath fallback of a local-file business resource. + * + *

The warning is deduplicated per resource path across the whole local-file access: the same resource path warns at + * most once, regardless of whether the first fallback happens at assembly time or at runtime. The deduplication set is + * shared by the loaders of one {@link PromptResourceAccess.LocalFileAccess} and is thread-safe. + */ +final class BuiltinFallbackWarnings { + + private static final Logger LOGGER = LoggerFactory.getLogger(BuiltinFallbackWarnings.class); + + private BuiltinFallbackWarnings() {} + + /** + * Warns once for a built-in fallback of the given resource path. + * + * @param warnedPaths thread-safe set of resource paths already warned about + * @param resourcePath path of the resource that fell back to the built-in classpath copy + */ + static void warnOnce(Set warnedPaths, String resourcePath) { + if (warnedPaths.add(resourcePath)) { + LOGGER.atWarn().log("prompt_resource_builtin_fallback path={} source=classpath", resourcePath); + } + } +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptScenarioCatalogLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptScenarioCatalogLoader.java index 3b4cb0b4..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 @@ -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; @@ -23,13 +23,12 @@ 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(); } 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 19add90b..afb8c7e3 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptSlotSchemaLoader.java @@ -1,7 +1,14 @@ package net.openan.a2at.sdk.prompt.resources.loader; import com.fasterxml.jackson.core.JsonProcessingException; -import net.openan.a2at.sdk.core.exception.SdkException; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +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.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; @@ -10,10 +17,19 @@ /** * Loads shared slot schemas from packaged classpath prompt resources. * + *

The slot types are probed in a fixed order first and then in the order the extension directories appear under + * {@code prompt_resources/slots/} on the classpath, so extensions bundled later are loadable without extending a + * hardcoded list. + * * @since 2026-06 */ public final class ClasspathPromptSlotSchemaLoader implements PromptSlotSchemaLoader { + private static final List KNOWN_SLOT_TYPES = + List.of(StandardTemplates.TASK_EXTENSION_NAME, StandardTemplates.NOTIFICATION_EXTENSION_NAME); + + private static final List SLOT_TYPES = discoverSlotTypes(); + private final ClasspathPromptResourceLoader resourceLoader; public ClasspathPromptSlotSchemaLoader(ClasspathPromptResourceLoader resourceLoader) { @@ -22,12 +38,54 @@ public ClasspathPromptSlotSchemaLoader(ClasspathPromptResourceLoader resourceLoa @Override public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { - String payload = resourceLoader.loadText(new PromptResourceKey("slots", scenarioCode, language, "slot.json")); + java.util.Optional parsed = TemplateUri.parse(scenarioCode); + if (parsed.isPresent()) { + TemplateUri uri = parsed.orElseThrow(); + return load(uri, scenarioCode, language); + } + for (String slotType : SLOT_TYPES) { + 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/*/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 SdkException("Failed to parse slot schema: " + scenarioCode, 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, StandardTemplates.NETWORK_LAYER_SEGMENT, + scenarioCode), + TemplateUri.of(slotType, scenarioCode)); + } + + private static List discoverSlotTypes() { + Set types = new LinkedHashSet<>(KNOWN_SLOT_TYPES); + try { + types.addAll(ClasspathResourceDirectories.list("prompt_resources/slots/")); + } catch (Exception ignored) { + // classpath enumeration is unavailable; fall back to the known types only } + return List.copyOf(types); } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptTemplateLoader.java index 3a4321f3..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 @@ -1,15 +1,34 @@ package net.openan.a2at.sdk.prompt.resources.loader; +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.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; import net.openan.a2at.sdk.resources.PromptResourceKey; /** * Loads shared prompt templates from packaged classpath prompt resources. * + *

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 */ public final class ClasspathPromptTemplateLoader implements PromptTemplateTextLoader { + 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(); + private final ClasspathPromptResourceLoader resourceLoader; public ClasspathPromptTemplateLoader(ClasspathPromptResourceLoader resourceLoader) { @@ -18,6 +37,43 @@ public ClasspathPromptTemplateLoader(ClasspathPromptResourceLoader resourceLoade @Override public String loadTemplate(String scenarioCode, String language) { - return resourceLoader.loadText(PromptResourceKey.template(scenarioCode, language, "template.md")); + Optional parsed = TemplateUri.parse(scenarioCode); + if (parsed.isPresent()) { + return resourceLoader.loadText(PromptResourceKey.template(parsed.orElseThrow(), language, "template.md")); + } + for (String templateType : TEMPLATE_TYPES) { + 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/*/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, StandardTemplates.NETWORK_LAYER_SEGMENT, + scenarioCode), + TemplateUri.of(templateType, scenarioCode)); + } + + private static List discoverTemplateTypes() { + Set types = new LinkedHashSet<>(KNOWN_TEMPLATE_TYPES); + try { + types.addAll(ClasspathResourceDirectories.list("prompt_resources/templates/")); + } catch (Exception ignored) { + // classpath enumeration is unavailable; fall back to the known types only + } + return List.copyOf(types); } } 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..0616c243 100644 --- a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptScenarioCatalogLoader.java @@ -1,37 +1,62 @@ 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 net.openan.a2at.sdk.core.exception.SdkException; +import java.util.Map; +import java.util.Set; +import net.openan.a2at.sdk.core.resources.PathSegments; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.resources.PromptResourceKey; /** * Loads shared scenario catalogs from one local prompt resource root. * + *

Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch the + * filesystem, so changes to the local files only take effect after the SDK is restarted. A local {@code scenarios.json} + * is no longer required: when it is missing, the loader falls back to the packaged classpath scenario catalog and + * reports the first fallback for the resource path with a one-time warning. + * * @since 2026-06 */ public final class LocalFilePromptScenarioCatalogLoader { + private final Map snapshot; private final Path promptRootDir; + private final ClasspathPromptScenarioCatalogLoader classpathLoader; + private final Set warnedFallbackPaths; - public LocalFilePromptScenarioCatalogLoader(Path promptRootDir) { + public LocalFilePromptScenarioCatalogLoader( + Map snapshot, + ClasspathPromptScenarioCatalogLoader classpathLoader, + Path promptRootDir, + Set warnedFallbackPaths) { + this.snapshot = snapshot; + this.classpathLoader = classpathLoader; this.promptRootDir = promptRootDir; + this.warnedFallbackPaths = warnedFallbackPaths; } public List load(String language) { - 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)) { + return parse(snapshot.get(pathKey), pathKey, language); } + List scenarios = classpathLoader.load(language); + BuiltinFallbackWarnings.warnOnce( + warnedFallbackPaths, PromptResourceKey.scenario(language, "scenarios.json").relativePath()); + return scenarios; + } + + private List parse(String payload, String pathKey, String language) { try { - return PromptResourceJsonParser.parse(Files.readString(catalogPath), ScenarioCatalog.class) + return PromptResourceJsonParser.parse(payload, ScenarioCatalog.class) .scenarios(); - } catch (IOException exception) { - throw new SdkException("Failed to read scenario catalog: " + catalogPath, exception); + } catch (JsonProcessingException 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/LocalFilePromptSlotSchemaLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptSlotSchemaLoader.java index 0d78ff25..03cdab1f 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,41 +1,100 @@ 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 net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.openan.a2at.sdk.core.resources.PathSegments; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotJsonSchema; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; /** * Loads shared slot schemas from one local prompt resource root. * + *

Resources are resolved against an assembly-time snapshot of the local root: runtime reads never touch the + * filesystem, so changes to the local files only take effect after the SDK is restarted. A schema that is missing from + * the local snapshot falls back to the packaged classpath copy, so customers only need to place the schemas they + * override under the local root; the first fallback for each resource path is reported with a one-time warning. + * * @since 2026-06 */ public final class LocalFilePromptSlotSchemaLoader implements PromptSlotSchemaLoader { + private final Map snapshot; private final Path promptRootDir; +private final ClasspathPromptSlotSchemaLoader classpathLoader; + private final Set warnedFallbackPaths; + private final List slotTypes; - public LocalFilePromptSlotSchemaLoader(Path promptRootDir) { + public LocalFilePromptSlotSchemaLoader( + Map snapshot, + ClasspathPromptSlotSchemaLoader classpathLoader, + Path promptRootDir, + Set warnedFallbackPaths) { + this.snapshot = snapshot; + this.classpathLoader = classpathLoader; this.promptRootDir = promptRootDir; + this.warnedFallbackPaths = warnedFallbackPaths; + this.slotTypes = LocalFileResourceSnapshot.typeDirectories(snapshot, "slots"); } @Override public PromptSlotSchema loadSlotSchema(String scenarioCode, String language) { - Path schemaPath = promptRootDir - .resolve("slots") - .resolve(scenarioCode) - .resolve(language) - .resolve("slot.json"); - if (!Files.exists(schemaPath)) { - throw new ResourceNotFoundException("Prompt resource file does not exist.", schemaPath.toString()); + PathSegments.requireSimpleRelativePath(scenarioCode, "Prompt slot schema scenario code"); + PathSegments.requireSimpleSegment(language, "Prompt slot schema language"); + String pathKey = resolvePathKey(scenarioCode, language); + if (pathKey != null && snapshot.containsKey(pathKey)) { + return parse(snapshot.get(pathKey), scenarioCode, promptRootDir.resolve(pathKey).toString(), language); + } + PromptSlotSchema schema = classpathLoader.loadSlotSchema(scenarioCode, language); + BuiltinFallbackWarnings.warnOnce(warnedFallbackPaths, fallbackPath(scenarioCode, language)); + return schema; + } + + private String resolvePathKey(String scenarioCode, String language) { + if (scenarioCode.contains("/")) { + return "slots/" + scenarioCode + "/" + language + "/slot.json"; } + for (String slotType : slotTypes) { + String candidate = resolveBareCode(slotType, scenarioCode, language); + if (snapshot.containsKey(candidate)) { + return candidate; + } + } + return null; + } + + /** + * Builds the resource path reported by the fallback warning: exact for path-form scenario codes, and the same + * wildcard locator the classpath loader uses for a bare scenario code. + */ + private static String fallbackPath(String scenarioCode, String language) { + if (scenarioCode.contains("/")) { + return "prompt_resources/slots/" + scenarioCode + "/" + language + "/slot.json"; + } + return "prompt_resources/slots/*/network-layer/" + scenarioCode + "/v1/" + language + + "/slot.json (or the layout without the network-layer segment)"; + } + + private static PromptSlotSchema parse(String payload, String scenarioCode, String resourcePath, String language) { try { - return PromptResourceJsonParser.parse(Files.readString(schemaPath), PromptSlotJsonSchema.class) + return PromptResourceJsonParser.parse(payload, PromptSlotJsonSchema.class) .toPromptSlotSchema(scenarioCode); - } catch (IOException exception) { - throw new SdkException("Failed to read slot schema resource: " + schemaPath, exception); + } catch (JsonProcessingException exception) { + throw ResourceReadErrors.readFailed(resourcePath, 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"; } } diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptTemplateLoader.java index fa1546d4..bae6c049 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,38 +1,67 @@ package net.openan.a2at.sdk.prompt.resources.loader; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; - -/** - * Loads shared prompt templates from one local prompt resource root. - * - * @since 2026-06 - */ +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.openan.a2at.sdk.core.resources.PathSegments; + public final class LocalFilePromptTemplateLoader implements PromptTemplateTextLoader { - private final Path promptRootDir; + private final Map snapshot; + private final ClasspathPromptTemplateLoader classpathLoader; + private final Set warnedFallbackPaths; + private final List templateTypes; - public LocalFilePromptTemplateLoader(Path promptRootDir) { - this.promptRootDir = promptRootDir; + public LocalFilePromptTemplateLoader( + Map snapshot, + ClasspathPromptTemplateLoader classpathLoader, + Set warnedFallbackPaths) { + this.snapshot = snapshot; + this.classpathLoader = classpathLoader; + this.warnedFallbackPaths = warnedFallbackPaths; + this.templateTypes = LocalFileResourceSnapshot.typeDirectories(snapshot, "templates"); } @Override public String loadTemplate(String scenarioCode, String language) { - Path templatePath = promptRootDir - .resolve("templates") - .resolve(scenarioCode) - .resolve(language) - .resolve("template.md"); - if (!Files.exists(templatePath)) { - throw new ResourceNotFoundException("Prompt resource file does not exist.", templatePath.toString()); + PathSegments.requireSimpleRelativePath(scenarioCode, "Prompt template scenario code"); + PathSegments.requireSimpleSegment(language, "Prompt template language"); + String pathKey = resolvePathKey(scenarioCode, language); + if (pathKey != null && snapshot.containsKey(pathKey)) { + return snapshot.get(pathKey); + } + String text = classpathLoader.loadTemplate(scenarioCode, language); + BuiltinFallbackWarnings.warnOnce(warnedFallbackPaths, fallbackPath(scenarioCode, language)); + return text; + } + + private String resolvePathKey(String scenarioCode, String language) { + if (scenarioCode.contains("/")) { + return "templates/" + scenarioCode + "/" + language + "/template.md"; + } + for (String templateType : templateTypes) { + String candidate = resolveBareCode(templateType, scenarioCode, language); + if (snapshot.containsKey(candidate)) { + return candidate; + } } - try { - return Files.readString(templatePath); - } catch (IOException exception) { - throw new SdkException("Failed to read template resource: " + templatePath, exception); + return null; + } + + 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 static String fallbackPath(String scenarioCode, String language) { + if (scenarioCode.contains("/")) { + return "prompt_resources/templates/" + scenarioCode + "/" + language + "/template.md"; } + return "prompt_resources/templates/*/network-layer/" + scenarioCode + "/v1/" + language + + "/template.md (or the layout without the network-layer segment)"; } -} +} \ No newline at end of file diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFileResourceSnapshot.java new file mode 100644 index 00000000..e8a312fb --- /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 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 e9a0d879..797dfa72 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,46 @@ 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.SdkException; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.core.model.A2ATConfigKeys; import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; 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 = "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()); } @@ -89,11 +102,29 @@ public String loadPrompt(String promptCategory, String language, String fileName } } + /** + * Local-file backing store for one prompt resource root. + * + *

The {@code warnedFallbackPaths} set is backed by a {@link ConcurrentHashMap} and is shared by the template, + * slot schema and scenario catalog loaders of this access, so each resource path is warned about its built-in + * fallback at most once even under concurrent access. + */ final class LocalFileAccess implements PromptResourceAccess { private final Path promptRootDir; + private final ClasspathPromptResourceLoader resourceLoader; + private final Map snapshot; + private final Set warnedFallbackPaths = ConcurrentHashMap.newKeySet(); + private final ClasspathPromptTemplateLoader classpathTemplateLoader; + private final ClasspathPromptSlotSchemaLoader classpathSlotSchemaLoader; + private final ClasspathPromptScenarioCatalogLoader classpathScenarioCatalogLoader; - private LocalFileAccess(Path promptRootDir) { + private LocalFileAccess(Path promptRootDir, ClasspathPromptResourceLoader resourceLoader) { this.promptRootDir = promptRootDir; + this.resourceLoader = resourceLoader; + this.snapshot = LocalFileResourceSnapshot.capture(promptRootDir); + this.classpathTemplateLoader = new ClasspathPromptTemplateLoader(resourceLoader); + this.classpathSlotSchemaLoader = new ClasspathPromptSlotSchemaLoader(resourceLoader); + this.classpathScenarioCatalogLoader = new ClasspathPromptScenarioCatalogLoader(resourceLoader); } @Override @@ -103,7 +134,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 +144,75 @@ public Path localRootDir() { @Override public List loadScenarios(String language) { - return new LocalFilePromptScenarioCatalogLoader(promptRootDir).load(language); + return new LocalFilePromptScenarioCatalogLoader( + snapshot, classpathScenarioCatalogLoader, promptRootDir, warnedFallbackPaths) + .load(language); } @Override public PromptTemplateTextLoader templateLoader() { - return new LocalFilePromptTemplateLoader(promptRootDir); + return new LocalFilePromptTemplateLoader(snapshot, classpathTemplateLoader, warnedFallbackPaths); } @Override public PromptSlotSchemaLoader slotSchemaLoader() { - return new LocalFilePromptSlotSchemaLoader(promptRootDir); + return new LocalFilePromptSlotSchemaLoader( + snapshot, classpathSlotSchemaLoader, promptRootDir, warnedFallbackPaths); } @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 SdkException("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/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..4b46c641 --- /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/resources/model/PromptSlotDefinition.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/resources/model/PromptSlotDefinition.java index 53e6d395..8be0d26e 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 @@ -13,7 +13,9 @@ * @param minimum optional numeric minimum * @param maximum optional numeric maximum * @param allowedValues optional enum constraint + * @param examples optional example values * @param description slot description + * @param valueConstraint human-readable value constraint description * @since 2026-06 */ public record PromptSlotDefinition( @@ -24,4 +26,6 @@ public record PromptSlotDefinition( @JsonProperty("minimum") Double minimum, @JsonProperty("maximum") Double maximum, @JsonProperty("enum") List allowedValues, - @JsonProperty("description") String description) {} + @JsonProperty("examples") List examples, + @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..ad1e809c 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; @@ -13,7 +12,9 @@ * @param minimum optional numeric minimum * @param maximum optional numeric maximum * @param allowedValues optional enum values + * @param examples optional example values * @param description slot description + * @param valueConstraint human-readable value constraint description * @since 2026-06 */ @JsonIgnoreProperties(ignoreUnknown = true) @@ -23,4 +24,6 @@ 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("examples") List examples, + @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..b9c45cd5 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,16 @@ 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, + null)); continue; } slotDefinitions.add(new PromptSlotDefinition( @@ -45,7 +54,9 @@ public PromptSlotSchema toPromptSlotSchema(String scenarioCode) { property.minimum(), property.maximum(), property.allowedValues(), - property.description())); + property.examples(), + property.description(), + property.valueConstraint())); } return new PromptSlotSchema(scenarioCode, List.copyOf(slotDefinitions)); } 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 new file mode 100644 index 00000000..2e199d51 --- /dev/null +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/DropBlankSlotSectionRenderer.java @@ -0,0 +1,117 @@ +package net.openan.a2at.sdk.prompt.taskrendering; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Sectioned template renderer with the drop blank-slot policy. + * + *

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

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

A sectioned template is split into sections on {@code ## } title lines; preamble handling is policy-specific — + * the drop policy discards content before the first title, while the collapse policy may retain it. A section whose first non-empty body line is a standalone slot placeholder line is a + * slot section driven by that slot; every other section is static and passes through with placeholder substitution. + * + *

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

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

The two policies are deliberately not merged: both are load-bearing for their extension families, so the grammar + * of section splitting lives here while each policy keeps its own slot-line and substitution rules. + * + * @since 2026-08 + */ +public interface SectionedTemplateRenderer { + + /** + * Renders one sectioned template text with the given slot values. + * + * @param templateText full template text whose sections are filled according to the implementation's policy + * @param slots slot values keyed by slot name; how null 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 + */ + @NonNull String render(@NonNull String templateText, @Nullable Map slots); +} diff --git a/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRenderer.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRenderer.java similarity index 64% 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 1cd390ef..2e553896 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,6 +1,7 @@ -package net.openan.a2at.sdk.prompt.taskrendering.api; +package net.openan.a2at.sdk.prompt.taskrendering; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -9,14 +10,31 @@ /** * Renders plain prompt bodies from lightweight template placeholders. * + *

This renderer carries the collapse blank-slot policy of {@link SectionedTemplateRenderer}: a slot section + * keeps its scaffolding and its standalone slot line collapses to the bare slot placeholder. + * + *

A section is identified as a slot section when its first non-empty body line begins with a double-braced + * placeholder {@code {{name}}}. Whatever follows the placeholder (marker text, parenthesized prose, nothing) is + * discarded on collapse. Single-brace example text (e.g. {@code {00:00~06:00,2Mbps}}) never triggers slot-section + * identification. + * * @since 2026-06 */ -public final class TaskPromptRenderer { +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*$"); + "^\\s*(\\{\\{([^{}]+)\\}\\}).*$"); /** * Renders a template by replacing slot placeholders with normalized slot values. @@ -25,26 +43,34 @@ public final class TaskPromptRenderer { * @param slots normalized slot values keyed by slot name * @return rendered prompt text */ + @Override public String render(String templateText, Map slots) { - if (templateText == null || !balancedBraces(templateText)) { - throw new TaskPromptRenderException("Template text is invalid."); + 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."); } - Matcher matcher = PLACEHOLDER_PATTERN.matcher(collapseSlotDrivenSections(templateText)); + Matcher matcher = PLACEHOLDER_PATTERN.matcher(collapseSlotDrivenSections(templateText, safeSlots)); StringBuffer rendered = new StringBuffer(); while (matcher.find()) { String slotName = matcher.group(1).trim(); - if (!slots.containsKey(slotName)) { + 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 = slots.get(slotName); + String replacement = safeSlots.get(slotName); matcher.appendReplacement(rendered, Matcher.quoteReplacement(Optional.ofNullable(replacement).orElse(""))); } matcher.appendTail(rendered); return rendered.toString(); } - private static String collapseSlotDrivenSections(String templateText) { + private static String collapseSlotDrivenSections(String templateText, Map slots) { String[] lines = normalizeLineEndings(templateText).split("\n", -1); StringBuilder collapsed = new StringBuilder(); int index = 0; @@ -60,7 +86,7 @@ private static String collapseSlotDrivenSections(String templateText) { nextSectionIndex++; } - appendCollapsedSection(collapsed, lines, index, nextSectionIndex, nextSectionIndex < lines.length); + appendCollapsedSection(collapsed, lines, index, nextSectionIndex, nextSectionIndex < lines.length, slots); index = nextSectionIndex; } return collapsed.toString(); @@ -75,7 +101,8 @@ private static void appendCollapsedSection( String[] lines, int sectionStart, int nextSectionStart, - boolean appendTrailingNewline) { + boolean appendTrailingNewline, + Map slots) { appendLine(collapsed, lines[sectionStart], true); Optional standaloneSlotMatcher = firstStandaloneSlotMatcher(lines, sectionStart + 1, nextSectionStart); @@ -86,6 +113,17 @@ private static void appendCollapsedSection( return; } + String slotName = standaloneSlotMatcher.get().group(2).trim(); + String slotValue = slots.get(slotName); + if (slotValue == null || slotValue.isBlank()) { + // Blank slot: keep only the section title followed by a single blank separator line; + // no placeholder line and no doubled separator. + if (appendTrailingNewline) { + collapsed.append('\n'); + } + return; + } + int firstEffectiveLineIndex = firstEffectiveLineIndex(lines, sectionStart + 1, nextSectionStart); for (int index = sectionStart + 1; index < firstEffectiveLineIndex; index++) { appendLine(collapsed, lines[index], true); 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..2251161d 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.SdkException; +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 SdkException { +public final class TaskPromptRenderException extends A2ATBusinessException { /** * Creates a task prompt rendering exception. * - * @param message failure message + * @param message failure message describing the rendering problem */ public TaskPromptRenderException(String message) { - super(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 new file mode 100644 index 00000000..b1b3b416 --- /dev/null +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidator.java @@ -0,0 +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); + } + } +} 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 new file mode 100644 index 00000000..6df3badf --- /dev/null +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidator.java @@ -0,0 +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.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/main/java/net/openan/a2at/sdk/prompt/validation/package-info.java b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/package-info.java new file mode 100644 index 00000000..9e299add --- /dev/null +++ b/a2a-t-prompt/src/main/java/net/openan/a2at/sdk/prompt/validation/package-info.java @@ -0,0 +1,2 @@ +/** Content validation implementations for the a2a-t Java SDK prompt module. */ +package net.openan.a2at.sdk.prompt.validation; \ No newline at end of file diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java index e985babe..f14bcbfc 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/DefaultStructuredPromptSlotValueExtractorTest.java @@ -1,6 +1,8 @@ package net.openan.a2at.sdk.prompt.analysis.impl; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import java.util.Map; @@ -15,9 +17,8 @@ class DefaultStructuredPromptSlotValueExtractorTest { @Test void extractSlotsParsesFormattedStructuredJsonPayload() { - RecordingClient llmClient = - new RecordingClient( - """ + RecordingClient llmClient = new RecordingClient( + """ { "slots": { "site": "Site A", @@ -33,10 +34,12 @@ void extractSlotsParsesFormattedStructuredJsonPayload() { (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, List.of( - new PromptSlotDefinition("site", true, "string", 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), + "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, @@ -45,12 +48,14 @@ void extractSlotsParsesFormattedStructuredJsonPayload() { 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.", "energy_saving", "en-US"); + extractor.extractSlots("Analyze Site A with critical severity.", "ran-energy-saving", "en-US"); assertEquals( Map.of( @@ -64,9 +69,8 @@ void extractSlotsParsesFormattedStructuredJsonPayload() { @Test void extractSlotsPreservesSlotTextContainingClosingBrace() { - RecordingClient llmClient = - new RecordingClient( - """ + RecordingClient llmClient = new RecordingClient( + """ { "slots": { "site": "Site A", @@ -80,23 +84,271 @@ void extractSlotsPreservesSlotTextContainingClosingBrace() { (scenarioCode, language) -> new PromptSlotSchema( scenarioCode, List.of( - new PromptSlotDefinition("site", true, "string", null, null, null, null, null), new PromptSlotDefinition( - "additional_notes", false, "string", null, null, null, null, null))), + "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.", "energy_saving", "en-US"); + 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; } @@ -107,7 +359,17 @@ public LLMResponse structured( Map jsonSchema, Double temperature, Integer maxTokens) { + this.lastMessages = messages; return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); } + + String lastUserContent() { + for (Map message : lastMessages) { + if ("user".equals(message.get("role"))) { + return message.get("content"); + } + } + return ""; + } } } diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/LlmScenarioRecognizerTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/LlmScenarioRecognizerTest.java new file mode 100644 index 00000000..cf17904a --- /dev/null +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/LlmScenarioRecognizerTest.java @@ -0,0 +1,203 @@ +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; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +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.prompt.analysis.exception.ScenarioRecognitionException; +import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; +import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import org.junit.jupiter.api.Test; + +class LlmScenarioRecognizerTest { + + @Test + void recognizeBuildsStructuredMessagesAndReturnsMatchedScenario() { + RecordingClient llmClient = + new RecordingClient("{\"matched\":true,\"scenario_code\":\"ran-energy-saving\",\"error_message\":null}"); + + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient); + ScenarioRecognitionResult result = recognizer.recognize( + "Please analyze site A energy usage.", + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "Identify the best matching scenario.", + "Choose from the provided scenario list."); + + assertTrue(result.matched()); + assertEquals("ran-energy-saving", result.scenarioCode()); + assertEquals(2, llmClient.lastMessages().size()); + assertEquals("system", llmClient.lastMessages().get(0).get("role")); + assertTrue(llmClient.lastMessages().get(1).get("content").contains("ran-energy-saving")); + assertTrue(llmClient.lastSchema().containsKey("required")); + } + + @Test + void recognizeRejectsMatchedPayloadWithoutScenarioCode() { + LLMClient llmClient = new RecordingClient("{\"matched\":true,\"scenario_code\":null,\"error_message\":null}"); + + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient); + + assertThrows( + ScenarioRecognitionException.class, + () -> recognizer.recognize( + "Analyze site A energy usage.", + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "Identify the best matching scenario.", + "Choose from the provided scenario list.")); + } + + @Test + void recognizeCanUseSharedJsonParserAbstraction() { + LLMClient llmClient = new RecordingClient("ignored"); + Map payload = new LinkedHashMap<>(); + payload.put("matched", true); + payload.put("scenario_code", "ran-energy-saving"); + payload.put("error_message", null); + RecordingJsonValueParser parser = new RecordingJsonValueParser(payload); + + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient, parser); + ScenarioRecognitionResult result = recognizer.recognize( + "Please analyze site A energy usage.", + List.of(new ScenarioDefinition( + "ran-energy-saving", "Energy Saving", "Energy analysis", "Analyze site power")), + "Identify the best matching scenario.", + "Choose from the provided scenario list."); + + assertTrue(result.matched()); + assertEquals("ran-energy-saving", result.scenarioCode()); + assertEquals("ignored", parser.lastPayload); + } + + @Test + void scenarioRecognizerImplementsScenarioRecognizer() { + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(new RecordingClient("ignored")); + assertInstanceOf(ScenarioRecognizer.class, recognizer); + } + + @Test + void recognizeReturnsUnmatchedWithErrorMessageWhenMatchedIsFalse() { + LLMClient llmClient = new RecordingClient( + "{\"matched\":false,\"scenario_code\":null,\"error_message\":\"No scenario matched.\"}"); + + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient); + ScenarioRecognitionResult result = recognizer.recognize( + "Unrelated input.", + List.of(new ScenarioDefinition( + "ran-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\"}"); + + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient); + ScenarioRecognitionResult result = recognizer.recognize( + "Ambiguous input.", + List.of(new ScenarioDefinition( + "ran-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\":\"ran-energy-saving\",\"error_message\":null}"); + + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient); + + assertThrows( + ScenarioRecognitionException.class, + () -> recognizer.recognize( + "Analyze site A energy usage.", + List.of(new ScenarioDefinition( + "ran-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}"); + + LlmScenarioRecognizer recognizer = new LlmScenarioRecognizer(llmClient); + + assertThrows( + ScenarioRecognitionException.class, + () -> recognizer.recognize( + "Analyze site A energy usage.", + List.of(new ScenarioDefinition( + "ran-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; + + 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()); + } + + List> lastMessages() { + return lastMessages; + } + + Map lastSchema() { + return lastSchema; + } + } + + private static final class RecordingJsonValueParser implements JsonValueParser { + private final Map result; + private String lastPayload; + + private RecordingJsonValueParser(Map result) { + this.result = result; + } + + @Override + public Map parseObject(String payload) { + this.lastPayload = payload; + return result; + } + } +} \ 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/ScenarioRecognizerTest.java deleted file mode 100644 index 214beb22..00000000 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/analysis/impl/ScenarioRecognizerTest.java +++ /dev/null @@ -1,126 +0,0 @@ -package net.openan.a2at.sdk.prompt.analysis.impl; - -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.LinkedHashMap; -import java.util.List; -import java.util.Map; -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.prompt.analysis.exception.ScenarioRecognitionException; -import net.openan.a2at.sdk.prompt.analysis.model.ScenarioRecognitionResult; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import org.junit.jupiter.api.Test; - -class ScenarioRecognizerTest { - - @Test - void recognizeBuildsStructuredMessagesAndReturnsMatchedScenario() { - RecordingClient llmClient = - new RecordingClient("{\"matched\":true,\"scenario_code\":\"energy_saving\",\"error_message\":null}"); - - ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient); - ScenarioRecognitionResult result = recognizer.recognize( - "Please analyze site A energy usage.", - List.of(new ScenarioDefinition( - "energy_saving", "Energy Saving", "Energy analysis", "Analyze site power")), - "Identify the best matching scenario.", - "Choose from the provided scenario list."); - - assertTrue(result.matched()); - assertEquals("energy_saving", result.scenarioCode()); - assertEquals(2, llmClient.lastMessages().size()); - assertEquals("system", llmClient.lastMessages().get(0).get("role")); - assertTrue(llmClient.lastMessages().get(1).get("content").contains("energy_saving")); - assertTrue(llmClient.lastSchema().containsKey("required")); - } - - @Test - void recognizeRejectsMatchedPayloadWithoutScenarioCode() { - LLMClient llmClient = - new RecordingClient("{\"matched\":true,\"scenario_code\":null,\"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 recognizeCanUseSharedJsonParserAbstraction() { - LLMClient llmClient = new RecordingClient("ignored"); - Map payload = new LinkedHashMap<>(); - payload.put("matched", true); - payload.put("scenario_code", "energy_saving"); - payload.put("error_message", null); - RecordingJsonValueParser parser = new RecordingJsonValueParser(payload); - - ScenarioRecognizer recognizer = new ScenarioRecognizer(llmClient, parser); - ScenarioRecognitionResult result = recognizer.recognize( - "Please 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."); - - assertTrue(result.matched()); - assertEquals("energy_saving", result.scenarioCode()); - assertEquals("ignored", parser.lastPayload); - } - - 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()); - } - - List> lastMessages() { - return lastMessages; - } - - Map lastSchema() { - return lastSchema; - } - } - - private static final class RecordingJsonValueParser implements JsonValueParser { - private final Map result; - private String lastPayload; - - private RecordingJsonValueParser(Map result) { - this.result = result; - } - - @Override - public Map parseObject(String payload) { - this.lastPayload = payload; - return result; - } - } -} 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..a4eb576c --- /dev/null +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/catalog/TemplateQueryServiceTest.java @@ -0,0 +1,272 @@ +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.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.LoggerFactory; + +class 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 classpathSourceTypeIgnoresConfiguredButNonexistentLocalRoot() { + Path missing = localRootDir.resolve("missing"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "classpath", missing.toString()); + + List templates = service.getPrompts(); + assertFalse(templates.isEmpty()); + assertTrue(templates.stream().allMatch(t -> CLASSPATH.equals(t.source()))); + } + + @Test + void localFileSourceTypeListsLocalUnionBuiltinBusinessAndClasspathNegotiation() throws IOException { + writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + 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")); + assertTrue(uris.contains("Task-T/network-layer/ran-energy-saving/v1")); + assertTrue(uris.contains("Notification-T/network-layer/subscribe-incident/v1")); + assertTrue(uris.contains("Authorization-T/authorization-policy-management/v1")); + for (String negotiationUri : NEGOTIATION_CLOSED_SET) { + assertTrue(uris.contains(negotiationUri)); + } + } + + @Test + void localFileSingleQueryFallsBackToBuiltinWhenMissingLocally() { + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + Optional template = service.getPrompt(StandardTemplates.ENERGY_SAVING); + + assertTrue(template.isPresent()); + assertEquals(CLASSPATH, template.get().source()); + } + + @Test + void localFileSamePathLocalOverridesBuiltinWithSingleLocalEntry() throws IOException { + writeTemplate(localRootDir, "Task-T/network-layer/ran-energy-saving/v1", "\nlocal body"); + + TemplateQueryService service = new TemplateQueryService(LANGUAGE, "local_file", localRootDir.toString()); + + List matches = service.getPrompts().stream() + .filter(t -> t.templateUri().uri().equals("Task-T/network-layer/ran-energy-saving/v1")) + .toList(); + + assertEquals(1, matches.size()); + assertEquals(LOCAL, matches.get(0).source()); + assertTrue(matches.get(0).content().contains("local body")); + } + + @Test + void localFileSourceTypeAnnotatesLocalAndClasspathSources() throws IOException { + writeTemplate(localRootDir, "Task-T/network-layer/custom-planning/v1", "\nbody"); + + 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()); + } + + @ParameterizedTest(name = "local-file source fails fast for an invalid root [{0}]") + @MethodSource("invalidLocalRoots") + void localFileSourceTypeWithInvalidRootFailsFast(String localRoot) { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, () -> new TemplateQueryService(LANGUAGE, "local_file", localRoot)); + + assertTrue(exception.getMessage().contains("A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR")); + } + + static Stream invalidLocalRoots() { + Path missing = Path.of(System.getProperty("java.io.tmpdir"), "a2a-t-prompt", "missing-" + System.nanoTime()); + return Stream.of( + Arguments.of((String) null), + Arguments.of(" "), + Arguments.of(missing.toString())); + } + + @Test + 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/ClasspathPromptLoadersTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/ClasspathPromptLoadersTest.java index 97e9cf9a..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 @@ -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; @@ -24,13 +24,13 @@ void loadScenarioCatalogMapsJacksonAnnotatedRecords() { List scenarios = new ClasspathPromptScenarioCatalogLoader(resourceLoader).load("en"); assertEquals(3, scenarios.size()); - assertEquals("handoff_planning", scenarios.get(0).scenarioCode()); + assertEquals("handoff-planning", scenarios.get(0).scenarioCode()); assertEquals("Cross-Team Handoff Planning", scenarios.get(0).scenarioName()); } @Test void loadTemplateReturnsExactMarkdownPayload() { - String template = new ClasspathPromptTemplateLoader(resourceLoader).loadTemplate("handoff_planning", "en"); + String template = new ClasspathPromptTemplateLoader(resourceLoader).loadTemplate("handoff-planning", "en"); template = normalizeLineEndings(template); assertEquals( """ @@ -62,9 +62,9 @@ void loadTemplateReturnsExactMarkdownPayload() { @Test void loadSlotSchemaMapsJacksonAnnotatedRecords() { PromptSlotSchema schema = - new ClasspathPromptSlotSchemaLoader(resourceLoader).loadSlotSchema("handoff_planning", "en"); + new ClasspathPromptSlotSchemaLoader(resourceLoader).loadSlotSchema("handoff-planning", "en"); - assertEquals("handoff_planning", schema.scenarioCode()); + assertEquals("handoff-planning", schema.scenarioCode()); assertEquals(7, schema.slotDefinitions().size()); assertEquals("task_id", schema.slotDefinitions().get(0).name()); assertEquals(true, schema.slotDefinitions().get(0).required()); @@ -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 @@ -82,13 +82,16 @@ void missingClasspathResourceReportsResolvedResourcePath() { assertThrows(ResourceNotFoundException.class, () -> new ClasspathPromptTemplateLoader(resourceLoader) .loadTemplate("missing_scenario", "en")); - assertEquals("prompt_resources/templates/missing_scenario/en/template.md", exception.resourcePath()); + assertEquals( + "prompt_resources/templates/*/network-layer/missing_scenario/v1/en/template.md" + + " (or the layout without the network-layer segment)", + exception.resourcePath()); } @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 052f58c1..b90dd30c 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/LocalFilePromptLoadersTest.java @@ -1,24 +1,56 @@ package net.openan.a2at.sdk.prompt.resources.loader; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.core.exception.A2ATError; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; +import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.slf4j.LoggerFactory; class LocalFilePromptLoadersTest { + private final ClasspathPromptResourceLoader resourceLoader = new ClasspathPromptResourceLoader(); + @TempDir Path promptRootDir; + private final ListAppender appender = new ListAppender<>(); + + private final Logger logger = (Logger) LoggerFactory.getLogger(BuiltinFallbackWarnings.class); + + @BeforeEach + void attachAppender() { + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void detachAppender() { + logger.detachAppender(appender); + appender.stop(); + } + @Test void loadScenarioCatalogMapsJacksonAnnotatedRecords() throws IOException { write( @@ -42,7 +74,9 @@ void loadScenarioCatalogMapsJacksonAnnotatedRecords() throws IOException { } """); - List scenarios = new LocalFilePromptScenarioCatalogLoader(promptRootDir).load("en"); + List scenarios = + new LocalFilePromptScenarioCatalogLoader(snapshot(), scenarioLoader(), promptRootDir, warnedPaths()) + .load("en"); assertEquals(2, scenarios.size()); assertEquals("incident_triage", scenarios.get(0).scenarioCode()); @@ -53,7 +87,10 @@ 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(), scenarioLoader(), promptRootDir, warnedPaths()) + .load("en")); } @Test @@ -61,7 +98,10 @@ void loadTemplateReadsExactLocalMarkdownText() throws IOException { write( promptRootDir .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") .resolve("incident_triage") + .resolve("v1") .resolve("en") .resolve("template.md"), """ @@ -71,7 +111,8 @@ void loadTemplateReadsExactLocalMarkdownText() throws IOException { Summary: {{summary}} """); - String template = new LocalFilePromptTemplateLoader(promptRootDir).loadTemplate("incident_triage", "en"); + String template = + new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths()).loadTemplate("incident_triage", "en"); assertEquals( """ @@ -83,12 +124,58 @@ void loadTemplateReadsExactLocalMarkdownText() throws IOException { template); } + @Test + void loadTemplateResolvesPathFormScenarioCodeFromLocalSnapshot() throws IOException { + write( + promptRootDir + .resolve("templates") + .resolve("Task-T/network-layer/ran-energy-saving/v1") + .resolve("en-US") + .resolve("template.md"), + "Local path-form template body."); + + String template = new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths()) + .loadTemplate("Task-T/network-layer/ran-energy-saving/v1", "en-US"); + + assertEquals("Local path-form template body.", template); + assertEquals(0, warningMessages().size(), "a local path-form hit must not fall back to the builtin template"); + } + + @Test + void loadSlotSchemaResolvesPathFormScenarioCodeFromLocalSnapshot() throws IOException { + write( + promptRootDir + .resolve("slots") + .resolve("Task-T/network-layer/ran-energy-saving/v1") + .resolve("en-US") + .resolve("slot.json"), + """ + { + "required": ["service"], + "properties": { + "service": {"type": "string", "description": "Affected service name"} + } + } + """); + + PromptSlotSchema schema = new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths()) + .loadSlotSchema("Task-T/network-layer/ran-energy-saving/v1", "en-US"); + + assertEquals("Task-T/network-layer/ran-energy-saving/v1", schema.scenarioCode()); + assertEquals(1, schema.slotDefinitions().size()); + assertEquals("service", schema.slotDefinitions().get(0).name()); + assertEquals(0, warningMessages().size(), "a local path-form hit must not fall back to the builtin schema"); + } + @Test void loadSlotSchemaMapsJacksonAnnotatedRecords() throws IOException { write( promptRootDir .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") .resolve("incident_triage") + .resolve("v1") .resolve("en") .resolve("slot.json"), """ @@ -112,8 +199,8 @@ void loadSlotSchemaMapsJacksonAnnotatedRecords() throws IOException { } """); - PromptSlotSchema schema = - new LocalFilePromptSlotSchemaLoader(promptRootDir).loadSlotSchema("incident_triage", "en"); + PromptSlotSchema schema = new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths()) + .loadSlotSchema("incident_triage", "en"); assertEquals("incident_triage", schema.scenarioCode()); assertEquals(3, schema.slotDefinitions().size()); @@ -123,52 +210,301 @@ 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()); } @Test - void missingTemplateIncludesResolvedLocalPathInResourceNotFoundException() { - ResourceNotFoundException exception = - assertThrows(ResourceNotFoundException.class, () -> new LocalFilePromptTemplateLoader(promptRootDir) - .loadTemplate("incident_triage", "en")); + void loadTemplateFallsBackToBuiltinWhenMissingLocally() { + Set warnedPaths = warnedPaths(); + + String template = + new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths) + .loadTemplate("ran-energy-saving", "en-US"); + + assertEquals(templateLoader().loadTemplate("ran-energy-saving", "en-US"), template); + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertTrueWarning( + warnings.get(0), + "prompt_resource_builtin_fallback path=prompt_resources/templates/*/network-layer/ran-energy-saving/v1/en-US/template.md" + + " (or the layout without the network-layer segment)"); + } + + @Test + void loadSlotSchemaFallsBackToBuiltinWhenMissingLocally() { + Set warnedPaths = warnedPaths(); + + PromptSlotSchema schema = new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths) + .loadSlotSchema("ran-energy-saving", "en-US"); + + assertEquals(slotLoader().loadSlotSchema("ran-energy-saving", "en-US").slotDefinitions(), schema.slotDefinitions()); + List warnings = warningMessages(); + assertEquals(1, warnings.size()); + assertTrueWarning( + warnings.get(0), + "prompt_resource_builtin_fallback path=prompt_resources/slots/*/network-layer/ran-energy-saving/v1/en-US/slot.json" + + " (or the layout without the network-layer segment)"); + } + + @Test + void loadScenarioCatalogFallsBackToBuiltinWhenMissingLocally() { + Set warnedPaths = warnedPaths(); + + List scenarios = + new LocalFilePromptScenarioCatalogLoader(snapshot(), scenarioLoader(), promptRootDir, warnedPaths) + .load("en-US"); + + assertEquals(scenarioLoader().load("en-US").size(), scenarios.size()); + assertEquals(1, warningMessages().size()); + } + + @Test + void missingTemplateFallsBackToClasspathThenThrowsNotFound() { + ResourceNotFoundException exception = assertThrows( + ResourceNotFoundException.class, + () -> new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths()) + .loadTemplate("incident_triage", "en-US")); assertEquals( - promptRootDir - .resolve("templates") - .resolve("incident_triage") - .resolve("en") - .resolve("template.md") - .toString(), + "prompt_resources/templates/*/network-layer/incident_triage/v1/en-US/template.md" + + " (or the layout without the network-layer segment)", + exception.resourcePath()); + assertEquals(0, warningMessages().size(), "a double-missing template must not emit a builtin fallback WARN"); + } + + @Test + void missingSlotSchemaFallsBackToClasspathThenThrowsNotFound() { + ResourceNotFoundException exception = assertThrows( + ResourceNotFoundException.class, + () -> new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths()) + .loadSlotSchema("incident_triage", "en-US")); + + assertEquals( + "prompt_resources/slots/*/network-layer/incident_triage/v1/en-US/slot.json" + + " (or the layout without the network-layer segment)", exception.resourcePath()); + assertEquals(0, warningMessages().size(), "a double-missing slot schema must not emit a builtin fallback WARN"); + } + + @Test + void missingScenarioCatalogFallsBackToClasspathThenThrowsNotFound() { + ResourceNotFoundException exception = assertThrows( + ResourceNotFoundException.class, + () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), scenarioLoader(), promptRootDir, warnedPaths()) + .load("xx")); + + assertEquals("prompt_resources/scenarios/xx/scenarios.json", exception.resourcePath()); + assertEquals(0, warningMessages().size(), "a double-missing scenario catalog must not emit a builtin fallback WARN"); } @Test - void malformedLocalSlotSchemaIsWrappedAsSdkException() throws IOException { + 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\": "); - SdkException exception = - assertThrows(SdkException.class, () -> new LocalFilePromptSlotSchemaLoader(promptRootDir) + A2ATError exception = assertThrows( + A2ATError.class, + () -> new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths()) .loadSlotSchema("incident_triage", "en")); + assertEquals("infra.resource_read_failed", exception.getCode()); + assertEquals(0, warningMessages().size(), "a malformed local slot schema must fail-fast without a builtin fallback WARN"); + } + + @Test + void snapshotFreezesResourcesAgainstRuntimeFileChanges() throws IOException { + write( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + "Original template {{service}}."); + write( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + """ + { + "required": ["service"], + "properties": { + "service": {"type": "string", "description": "Affected service name"} + } + } + """); + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "incident_triage", + "scenario_name": "Incident Triage", + "description": "Classify and route a production incident.", + "example": "Investigate elevated API latency." + } + ] + } + """); + + PromptResourceAccess access = createAccess(); + + Files.writeString( + promptRootDir + .resolve("templates") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("template.md"), + "Mutated template."); + Files.writeString( + promptRootDir + .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") + .resolve("incident_triage") + .resolve("v1") + .resolve("en") + .resolve("slot.json"), + "{\"properties\":{}}"); + write( + promptRootDir.resolve("scenarios").resolve("en").resolve("scenarios.json"), + """ + { + "scenarios": [ + { + "scenario_code": "mutated", + "scenario_name": "Mutated", + "description": "Mutated after snapshot.", + "example": "Mutated example." + } + ] + } + """); + + assertEquals("Original template {{service}}.", access.templateLoader().loadTemplate("incident_triage", "en")); assertEquals( - "Failed to read slot schema resource: " - + promptRootDir - .resolve("slots") - .resolve("incident_triage") - .resolve("en") - .resolve("slot.json"), - exception.getMessage()); + "service", + access.slotSchemaLoader() + .loadSlotSchema("incident_triage", "en") + .slotDefinitions() + .get(0) + .name()); + assertEquals("incident_triage", access.loadScenarios("en").get(0).scenarioCode()); + } + + @Test + void loadTemplateRejectsTraversalOrBlankScenarioPathSegments() { + assertThrows( + IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths()) + .loadTemplate("../etc/passwd", "en")); + assertThrows( + IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths()) + .loadTemplate("Task-T//network-layer/x", "en")); + assertThrows( + IllegalArgumentException.class, () -> new LocalFilePromptTemplateLoader(snapshot(), templateLoader(), warnedPaths()) + .loadTemplate("incident_triage", "en/../admin")); + } + + @Test + void loadSlotSchemaRejectsNonSimpleLanguage() { + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths()) + .loadSlotSchema("incident_triage", "../en")); + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptSlotSchemaLoader(snapshot(), slotLoader(), promptRootDir, warnedPaths()) + .loadSlotSchema("Task-T/network-layer/..", "en")); + } + + @Test + void loadScenarioCatalogRejectsNonSimpleLanguage() { + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), scenarioLoader(), promptRootDir, warnedPaths()) + .load("en/../admin")); + assertThrows( + IllegalArgumentException.class, + () -> new LocalFilePromptScenarioCatalogLoader(snapshot(), scenarioLoader(), promptRootDir, warnedPaths()) + .load(" ")); + } + + @Test + void localSnapshotKeepsPromptsOnClasspath() throws IOException { + write( + promptRootDir + .resolve("prompts") + .resolve("scenario_recognition") + .resolve("en-US") + .resolve("system.md"), + "Local prompt copy."); + + PromptResourceAccess access = createAccess(); + + assertFalse(access.classpath()); + assertNotEquals("Local prompt copy.", access.loadPrompt("scenario_recognition", "en-US", "system.md")); + } + + private PromptResourceAccess createAccess() { + return PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + } + + private Map snapshot() { + return LocalFileResourceSnapshot.capture(promptRootDir); + } + + private ClasspathPromptTemplateLoader templateLoader() { + return new ClasspathPromptTemplateLoader(resourceLoader); + } + + private ClasspathPromptSlotSchemaLoader slotLoader() { + return new ClasspathPromptSlotSchemaLoader(resourceLoader); + } + + private ClasspathPromptScenarioCatalogLoader scenarioLoader() { + return new ClasspathPromptScenarioCatalogLoader(resourceLoader); + } + + private static Set warnedPaths() { + return ConcurrentHashMap.newKeySet(); + } + + private List warningMessages() { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void assertTrueWarning(String message, String... fragments) { + for (String fragment : fragments) { + org.junit.jupiter.api.Assertions.assertTrue( + message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); + } } private static void write(Path file, String content) throws IOException { Files.createDirectories(file.getParent()); Files.writeString(file, content); } -} +} \ No newline at end of file diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java index 6cc38a7f..1ee9f69a 100644 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/resources/loader/PromptResourceAccessTest.java @@ -3,36 +3,117 @@ 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.stream.Stream; import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; 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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.LoggerFactory; class PromptResourceAccessTest { @TempDir Path promptRootDir; + private final ListAppender accessAppender = new ListAppender<>(); + + private final Logger accessLogger = (Logger) LoggerFactory.getLogger(PromptResourceAccess.class); + + private final ListAppender fallbackAppender = new ListAppender<>(); + + private final Logger fallbackLogger = (Logger) LoggerFactory.getLogger(BuiltinFallbackWarnings.class); + + @BeforeEach + void attachAppender() { + accessAppender.start(); + accessLogger.addAppender(accessAppender); + fallbackAppender.start(); + fallbackLogger.addAppender(fallbackAppender); + } + + @AfterEach + void detachAppender() { + accessLogger.detachAppender(accessAppender); + fallbackLogger.detachAppender(fallbackAppender); + accessAppender.stop(); + fallbackAppender.stop(); + } + @Test - void createLocalFileAccessLoadsPromptsTemplatesScenariosAndSlotsFromSameRoot() throws IOException { + void classpathModeIgnoresConfiguredLocalRootWithSingleWarning() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "classpath", promptRootDir.toString())); + + assertTrue(access.classpath()); + List warnings = warningMessages(accessAppender); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), "prompt_resource_local_root_ignored", "root=" + promptRootDir, "source=classpath"); + } + + @ParameterizedTest(name = "local-file mode fails fast for an invalid root [{0}]") + @MethodSource("invalidLocalRoots") + void localFileModeFailsFastForInvalidRoot(String localRoot) { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", localRoot))); + + assertContains(exception.getMessage(), "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR"); + } + + static Stream invalidLocalRoots() { + Path missing = Path.of(System.getProperty("java.io.tmpdir"), "a2a-t-prompt", "missing-" + System.nanoTime()); + return Stream.of( + Arguments.of((String) null), + Arguments.of(" "), + Arguments.of(missing.toString())); + } + + @Test + 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") + .resolve("Task-T") + .resolve("network-layer") .resolve("incident_triage") + .resolve("v1") .resolve("en") .resolve("template.md"), "Local template for {{service}}."); @@ -53,7 +134,10 @@ void createLocalFileAccessLoadsPromptsTemplatesScenariosAndSlotsFromSameRoot() t write( promptRootDir .resolve("slots") + .resolve("Task-T") + .resolve("network-layer") .resolve("incident_triage") + .resolve("v1") .resolve("en") .resolve("slot.json"), """ @@ -70,8 +154,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"); @@ -81,21 +163,112 @@ void createLocalFileAccessLoadsPromptsTemplatesScenariosAndSlotsFromSameRoot() t } @Test - void localPromptResourceNotFoundIncludesResolvedPromptPath() { + void localFileModeFallsBackToBuiltinTemplateWhenMissingLocally() { PromptResourceAccess access = PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); - ResourceNotFoundException exception = - assertThrows(ResourceNotFoundException.class, () -> access.loadPrompt("analysis", "en", "missing.md")); + String template = access.templateLoader().loadTemplate("ran-energy-saving", "en-US"); + + assertTrue(template.startsWith("## Operation Type"), "the built-in template should be loaded on fallback"); + assertTrue(template.contains("{{operation_type}}"), "the built-in template should carry its slot placeholders"); + List warnings = warningMessages(fallbackAppender); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), + "prompt_resource_builtin_fallback", + "path=prompt_resources/templates/*/network-layer/ran-energy-saving/v1/en-US/template.md", + "source=classpath"); + } + + @Test + void localFileModeFallsBackToBuiltinSlotSchemaWhenMissingLocally() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + PromptSlotSchema schema = access.slotSchemaLoader().loadSlotSchema("ran-energy-saving", "en-US"); + + assertEquals("ran-energy-saving", schema.scenarioCode()); + assertFalse(schema.slotDefinitions().isEmpty()); + List warnings = warningMessages(fallbackAppender); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), + "prompt_resource_builtin_fallback", + "path=prompt_resources/slots/*/network-layer/ran-energy-saving/v1/en-US/slot.json", + "source=classpath"); + } - assertEquals( + @Test + void localFileModeFallsBackToBuiltinScenarioCatalogWhenMissingLocally() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + List scenarios = access.loadScenarios("en-US"); + + assertFalse(scenarios.isEmpty(), "a missing local scenarios.json must fall back to the built-in scenario catalog"); + List warnings = warningMessages(fallbackAppender); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), + "prompt_resource_builtin_fallback", + "path=prompt_resources/scenarios/en-US/scenarios.json", + "source=classpath"); + } + + @Test + void builtinFallbackWarnsOnlyOncePerResourcePathAcrossLoaderInstances() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + access.templateLoader().loadTemplate("ran-energy-saving", "en-US"); + access.templateLoader().loadTemplate("ran-energy-saving", "en-US"); + + assertEquals(1, warningMessages(fallbackAppender).size(), "the shared dedup set must warn only once per path"); + } + + @Test + void localFileModeIgnoresUnsupportedLocalDirectoriesWithSingleWarning() throws IOException { + write( promptRootDir .resolve("prompts") - .resolve("analysis") - .resolve("en") - .resolve("missing.md") - .toString(), - exception.resourcePath()); + .resolve("scenario_recognition") + .resolve("en-US") + .resolve("system.md"), + "Local prompt copy."); + write( + promptRootDir + .resolve("templates") + .resolve("Negotiation-T") + .resolve("information-negotiation") + .resolve("propose") + .resolve("v1") + .resolve("en-US") + .resolve("template.md"), + "Local negotiation template copy."); + write(promptRootDir.resolve("negotiation-vocabulary").resolve("en-US").resolve("vocabulary.json"), "{}"); + + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + List warnings = warningMessages(accessAppender); + assertEquals(1, warnings.size()); + assertContains( + warnings.get(0), + "prompt_resource_local_directories_ignored", + "prompts", + "templates/Negotiation-T", + "negotiation-vocabulary", + "reason=classpath_fixed"); + } + + @Test + void localFileModePromptLoadUsesClasspathPathForMissingResource() { + PromptResourceAccess access = + PromptResourceAccess.create(new PromptRuntimeConfig("en-US", "local_file", promptRootDir.toString())); + + ResourceNotFoundException exception = + assertThrows(ResourceNotFoundException.class, () -> access.loadPrompt("analysis", "en", "missing.md")); + + assertEquals("prompt_resources/prompts/analysis/en/missing.md", exception.resourcePath()); } @Test @@ -108,8 +281,21 @@ void unsupportedSourceTypeFailsFast() { assertEquals("Unsupported prompt source type: database", exception.getMessage()); } + private static List warningMessages(ListAppender appender) { + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static void assertContains(String message, String... fragments) { + for (String fragment : fragments) { + assertTrue(message.contains(fragment), "expected [" + message + "] to contain [" + fragment + "]"); + } + } + private static void write(Path file, String content) throws IOException { Files.createDirectories(file.getParent()); Files.writeString(file, content); } -} +} \ No newline at end of file diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/DropBlankSlotSectionRendererTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/DropBlankSlotSectionRendererTest.java new file mode 100644 index 00000000..83414ed3 --- /dev/null +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/DropBlankSlotSectionRendererTest.java @@ -0,0 +1,203 @@ +package net.openan.a2at.sdk.prompt.taskrendering; + +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; + +class DropBlankSlotSectionRendererTest { + + private final DropBlankSlotSectionRenderer renderer = new DropBlankSlotSectionRenderer(); + + @Test + void rendersSlotSectionWithLowercaseEnglishMarker() { + String template = + """ + ## Slot + {{slot}} (required) + Requirements: + Some requirements. + """; + + String rendered = renderer.render(template, Map.of("slot", "value")); + + assertEquals("## Slot\nvalue", rendered); + } + + @Test + void rendersSlotSectionWithFullWidthChineseMarker() { + String template = + """ + ## 槽位 + {{槽位}}(必填) + 要求: + 一些要求。 + """; + + String rendered = renderer.render(template, Map.of("槽位", "值")); + + assertEquals("## 槽位\n值", rendered); + } + + @Test + void rendersSlotSectionWithChineseOptionalVariant() { + String template = + """ + ## 订阅条件 + {{订阅条件}}(可选) + 要求: + 提供订阅条件。 + """; + + String rendered = renderer.render(template, Map.of("订阅条件", "critical")); + + assertEquals("## 订阅条件\ncritical", rendered); + } + + @Test + void rendersSlotSectionWithChineseRequiredVariant() { + String template = + """ + ## 通知主题 + {{通知主题}}(必选) + 要求: + 提供通知主题。 + """; + + String rendered = renderer.render(template, Map.of("通知主题", "Incident")); + + assertEquals("## 通知主题\nIncident", rendered); + } + + @Test + void rendersSlotSectionWithBarePlaceholderNoMarker() { + String template = + """ + ## Slot + {{slot}} + Requirements: + Some requirements. + """; + + String rendered = renderer.render(template, Map.of("slot", "value")); + + assertEquals("## Slot\nvalue", rendered); + } + + @Test + void rendersSlotSectionWithLongConditionalSuffix() { + String template = + """ + ## Expected Output + {{expected_output}} (optional when creating, not required when modifying) + Requirement: + Describe the output format. + """; + + String rendered = renderer.render(template, Map.of("expected_output", "Report.")); + + assertEquals("## Expected Output\nReport.", rendered); + } + + @Test + void dropsSlotSectionWhenValueIsNull() { + String template = + """ + ## Slot + {{slot}} (required) + Requirements: + Some requirements. + """; + + String rendered = renderer.render(template, Map.of()); + + assertEquals("", rendered); + } + + @Test + void dropsSlotSectionWhenValueIsBlank() { + String template = + """ + ## Slot + {{slot}} (required) + Requirements: + Some requirements. + """; + + String rendered = renderer.render(template, Map.of("slot", " ")); + + assertEquals("", rendered); + } + + @Test + void keepsStaticSectionPassesThrough() { + String template = + """ + ## Static + Some static content. + """; + + String rendered = renderer.render(template, Map.of()); + + assertEquals("## Static\nSome static content.", rendered); + } + + @Test + void joinsSectionsWithSingleBlankLine() { + String template = + """ + ## Section A + Content A. + + ## Section B + Content B. + """; + + String rendered = renderer.render(template, Map.of()); + + assertEquals("## Section A\nContent A.\n\n## Section B\nContent B.", rendered); + } + + @Test + void returnsEmptyStringWhenAllSectionsDropped() { + String template = + """ + ## Slot A + {{slot_a}} (required) + Requirements. + + ## Slot B + {{slot_b}} (required) + Requirements. + """; + + String rendered = renderer.render(template, Map.of()); + + assertEquals("", rendered); + } + + @Test + void rejectsNullTemplate() { + assertThrows(NullPointerException.class, () -> renderer.render(null, Map.of())); + } + + @Test + void doesNotIdentifySlotSectionWhenFirstLineIsSingleBraceExample() { + String template = + """ + ## Section + {00:00~06:00,2Mbps} + + {{slot}} (optional) + """; + + String rendered = renderer.render(template, Map.of("slot", "value")); + + assertTrue(rendered.contains("{00:00~06:00,2Mbps}")); + assertTrue(rendered.contains("value")); + assertTrue(rendered.contains("(optional)")); + } +} \ No newline at end of file diff --git a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/RealTemplateRenderingContractTest.java b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/RealTemplateRenderingContractTest.java new file mode 100644 index 00000000..dc182a88 --- /dev/null +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/RealTemplateRenderingContractTest.java @@ -0,0 +1,130 @@ +package net.openan.a2at.sdk.prompt.taskrendering; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; +import org.junit.jupiter.api.Test; + +/** + * Contract tests that verify real template resources from the classpath are rendered correctly: + * scaffolding text (Requirement:/要求:) must be removed from rendered output. + */ +class RealTemplateRenderingContractTest { + + private final TaskPromptRenderer taskRenderer = new TaskPromptRenderer(); + private final DropBlankSlotSectionRenderer dropRenderer = new DropBlankSlotSectionRenderer(); + + @Test + void energySavingEnUsTemplateCollapsesScaffolding() { + String template = loadTemplate( + "prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/en-US/template.md"); + String rendered = taskRenderer.render(template, Map.of( + "operation_type", "Create", + "task_description", "Energy saving plan.", + "task_object", "Area: Songshanhu", + "task_target", "Maximize energy saving.", + "task_context", "NR; full period.", + "expected_output", "Report.")); + + assertFalse(rendered.contains("Requirement:")); + assertTrue(rendered.contains("Create")); + assertTrue(rendered.contains("Energy saving plan.")); + assertTrue(rendered.contains("Maximize energy saving.")); + } + + @Test + void energySavingZhCnTemplateCollapsesScaffolding() { + String template = loadTemplate( + "prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/zh-CN/template.md"); + String rendered = taskRenderer.render(template, Map.of( + "操作类型", "创建", + "任务描述", "节能方案。", + "任务对象", "区域:松山湖", + "任务目标", "最大化节能。", + "任务上下文", "NR;全时段。", + "预期输出", "报告。")); + + assertFalse(rendered.contains("要求:")); + assertTrue(rendered.contains("创建")); + assertTrue(rendered.contains("节能方案。")); + assertTrue(rendered.contains("最大化节能。")); + } + + @Test + void authorizationEnUsTemplateCollapsesScaffolding() { + String template = loadTemplate( + "prompt_resources/templates/Authorization-T/authorization-policy-management/v1/en-US/template.md"); + String rendered = taskRenderer.render(template, Map.of( + "authorization_policy_operation_type", "add authorization policy", + "network_operation_authorization_policy_list", "Policy list content.")); + + assertFalse(rendered.contains("Requirement:")); + assertTrue(rendered.contains("add authorization policy")); + assertTrue(rendered.contains("Policy list content.")); + } + + @Test + void authorizationZhCnTemplateCollapsesScaffolding() { + String template = loadTemplate( + "prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md"); + String rendered = taskRenderer.render(template, Map.of( + "授权策略的操作类型", "新增授权策略", + "动网操作的授权策略列表", "策略列表内容。")); + + assertFalse(rendered.contains("要求:")); + assertTrue(rendered.contains("新增授权策略")); + assertTrue(rendered.contains("策略列表内容。")); + } + + @Test + void subscribeIncidentZhCnTemplateCollapsesScaffolding() { + String template = loadTemplate( + "prompt_resources/templates/Notification-T/network-layer/subscribe-incident/v1/zh-CN/template.md"); + String rendered = taskRenderer.render(template, Map.of( + "通知主题", "Incident", + "订阅条件", "严重", + "上报通知数据格式", "DataPart")); + + assertFalse(rendered.contains("要求:")); + assertTrue(rendered.contains("Incident")); + assertTrue(rendered.contains("DataPart")); + } + + @Test + void serviceRecoveryZhCnTemplateCollapsesScaffolding() { + String template = loadTemplate( + "prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md"); + String rendered = taskRenderer.render(template, Map.of( + "订阅条件", "子网:xx", + "上报通知数据格式", "数据格式内容。")); + + assertFalse(rendered.contains("要求:")); + assertTrue(rendered.contains("数据格式内容。")); + } + + @Test + void negotiationZhCnTemplateDropsBlankSlotSections() { + String template = loadTemplate( + "prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md"); + String rendered = dropRenderer.render(template, Map.of( + "所需信息项", "1. 信息项")); + + assertFalse(rendered.contains("要求:")); + assertTrue(rendered.contains("信息项")); + } + + private static String loadTemplate(String classpathResource) { + try (java.io.InputStream stream = RealTemplateRenderingContractTest.class + .getClassLoader() + .getResourceAsStream(classpathResource)) { + if (stream == null) { + throw new AssertionError("Template not found on classpath: " + classpathResource); + } + return new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + } catch (java.io.IOException e) { + throw new RuntimeException("Failed to read template: " + classpathResource, e); + } + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..61ad0202 --- /dev/null +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/TaskPromptRendererTest.java @@ -0,0 +1,224 @@ +package net.openan.a2at.sdk.prompt.taskrendering; + +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.prompt.taskrendering.exception.TaskPromptRenderException; +import org.junit.jupiter.api.Test; + +class TaskPromptRendererTest { + + private final TaskPromptRenderer renderer = new TaskPromptRenderer(); + + @Test + void renderBuildsPlainPromptBody() { + String prompt = renderer.render( + "Site: {site}\nNotes: {additional_notes}", Map.of("site", "Site A", "additional_notes", "")); + + assertEquals("Site: Site A\nNotes: ", prompt); + } + + @Test + void renderKeepsOnlyTitleAndSingleBlankSeparatorForBlankSlotSection() { + String prompt = renderer.render( + "## Task Type\n" + + "{{task_type}} (Required)\n\n" + + "Requirement: provide the task type.\n\n" + + "## Condition\n" + + "{{condition}} (Optional)\n\n" + + "Requirement: describe the condition.\n\n" + + "## Expected Output\n" + + "{{expected_output}} (Optional)\n", + Map.of( + "task_type", "Diagnosis", + "condition", "", + "expected_output", "Return a structured diagnosis result.")); + + assertEquals( + "## Task Type\n" + + "Diagnosis\n\n" + + "## Condition\n" + + "\n" + + "## Expected Output\n" + + "Return a structured diagnosis result.\n", + prompt); + } + + @Test + void renderSupportsDoubleBracedPlaceholders() { + String prompt = renderer.render( + "Topic: {{topic}}\nCondition: {{condition}}", + Map.of("topic", "Incident", "condition", "critical alert")); + + assertEquals("Topic: Incident\nCondition: critical alert", prompt); + } + + @Test + void renderSupportsDoubleBracedPlaceholdersInLongerTemplate() { + String prompt = + renderer.render( + "Topic: {{topic}}\nCondition: {{condition}}", + Map.of("topic", "Incident", "condition", "Severity is critical")); + + assertEquals("Topic: Incident\nCondition: Severity is critical", prompt); + } + + @Test + void renderCollapsesSectionBodyWhenFirstEffectiveLineIsStandaloneSlotWithEnglishSuffix() { + String prompt = renderer.render( + "## Task Type\n" + + "Diagnosis\n\n" + + "## Task Target\n" + + "{{task_target}}(Required)\n\n" + + "Requirement: explain the target.\n" + + "Example: complete the diagnosis.\n\n" + + "## Expected Output\n" + + "{{expected_output}}(Optional)\n", + Map.of( + "task_target", "Complete the diagnosis and provide remediation advice.", + "expected_output", "Return a structured diagnosis result.")); + + assertEquals( + "## Task Type\n" + + "Diagnosis\n\n" + + "## Task Target\n" + + "Complete the diagnosis and provide remediation advice.\n\n" + + "## Expected Output\n" + + "Return a structured diagnosis result.\n", + prompt); + } + + @Test + void renderCollapsesSectionBodyWhenFirstEffectiveLineIsStandaloneSlotWithParenthesizedSuffix() { + String prompt = renderer.render( + "## Task Target\n" + + "{{task_target}} (Required)\n\n" + + "Requirement: explain the target.\n" + + "Example: complete the diagnosis.\n\n" + + "## Expected Output\n" + + "{{expected_output}} (Optional)\n", + Map.of( + "task_target", "Complete the fault diagnosis and provide remediation advice.", + "expected_output", "Return a structured diagnosis result.")); + + assertEquals( + "## Task Target\n" + + "Complete the fault diagnosis and provide remediation advice.\n\n" + + "## Expected Output\n" + + "Return a structured diagnosis result.\n", + prompt); + } + + @Test + void renderPreservesRegularInlinePlaceholderContent() { + String prompt = renderer.render( + "## Subscription\n" + + "Please subscribe to {{topic}} incidents.\n\n" + + "## Condition\n" + + "{{condition}} (Optional)\n" + + "Requirement: describe the filter.\n", + Map.of("topic", "network", "condition", "critical only")); + + assertEquals( + "## Subscription\n" + + "Please subscribe to network incidents.\n\n" + + "## Condition\n" + + "critical only\n", + prompt); + } + + @Test + void renderRaisesWhenTemplateReferencesUnknownSlot() { + assertThrows( + TaskPromptRenderException.class, + () -> 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 + 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"))); + } + + @Test + void renderCollapsesSectionWithLowercaseEnglishMarker() { + String prompt = renderer.render( + "## Task Target\n" + + "{{task_target}} (required)\n\n" + + "Requirement: explain the target.\n\n" + + "## Expected Output\n" + + "{{expected_output}} (optional)\n", + Map.of("task_target", "Do X.", "expected_output", "Y.")); + + assertEquals( + "## Task Target\nDo X.\n\n## Expected Output\nY.\n", prompt); + } + + @Test + void renderCollapsesSectionWithFullWidthChineseMarker() { + String prompt = renderer.render( + "## 操作类型\n" + + "{{操作类型}}(必填)\n\n" + + "要求:\n" + + "请提供操作类型。\n\n" + + "## 任务目标\n" + + "{{任务目标}}(选填)\n", + Map.of("操作类型", "创建", "任务目标", "节能最大化")); + + assertEquals( + "## 操作类型\n创建\n\n## 任务目标\n节能最大化\n", prompt); + } + + @Test + void renderCollapsesSectionWithLongConditionalSuffix() { + String prompt = renderer.render( + "## Expected Output\n" + + "{{expected_output}} (optional when creating, not required when modifying)\n\n" + + "Requirement: describe the output format.\n", + Map.of("expected_output", "Report.")); + + assertEquals( + "## Expected Output\nReport.\n", prompt); + } + + @Test + void renderCollapsesSectionWithBarePlaceholderNoMarker() { + String prompt = renderer.render( + "## Task Target\n" + + "{{task_target}}\n\n" + + "Requirement: explain the target.\n", + Map.of("task_target", "Do X.")); + + assertEquals( + "## Task Target\nDo X.\n", prompt); + } + + @Test + void renderDoesNotCollapseWhenFirstEffectiveLineIsSingleBraceExample() { + String prompt = renderer.render( + "## Task Context\n" + + "{00:00~06:00,2Mbps}\n\n" + + "{{task_context}} (optional)\n", + Map.of("task_context", "Context value.")); + + assertEquals( + "## Task Context\n{00:00~06:00,2Mbps}\n\nContext value. (optional)\n", prompt); + } +} 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 deleted file mode 100644 index 45f5c7cb..00000000 --- a/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/taskrendering/api/TaskPromptRendererTest.java +++ /dev/null @@ -1,111 +0,0 @@ -package net.openan.a2at.sdk.prompt.taskrendering.api; - -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.prompt.taskrendering.exception.TaskPromptRenderException; -import org.junit.jupiter.api.Test; - -class TaskPromptRendererTest { - - private final TaskPromptRenderer renderer = new TaskPromptRenderer(); - - @Test - void renderBuildsPlainPromptBody() { - String prompt = renderer.render( - "Site: {site}\nNotes: {additional_notes}", Map.of("site", "Site A", "additional_notes", "")); - - assertEquals("Site: Site A\nNotes: ", prompt); - } - - @Test - void renderSupportsDoubleBracedPlaceholders() { - String prompt = renderer.render( - "Topic: {{topic}}\nCondition: {{condition}}", - Map.of("topic", "Incident", "condition", "critical alert")); - - assertEquals("Topic: Incident\nCondition: critical alert", prompt); - } - - @Test - void renderSupportsDoubleBracedPlaceholdersInLongerTemplate() { - String prompt = - renderer.render( - "Topic: {{topic}}\nCondition: {{condition}}", - Map.of("topic", "Incident", "condition", "Severity is critical")); - - assertEquals("Topic: Incident\nCondition: Severity is critical", prompt); - } - - @Test - void renderCollapsesSectionBodyWhenFirstEffectiveLineIsStandaloneSlotWithEnglishSuffix() { - String prompt = renderer.render( - "## Task Type\n" - + "Diagnosis\n\n" - + "## Task Target\n" - + "{{task_target}}(Required)\n\n" - + "Requirement: explain the target.\n" - + "Example: complete the diagnosis.\n\n" - + "## Expected Output\n" - + "{{expected_output}}(Optional)\n", - Map.of( - "task_target", "Complete the diagnosis and provide remediation advice.", - "expected_output", "Return a structured diagnosis result.")); - - assertEquals( - "## Task Type\n" - + "Diagnosis\n\n" - + "## Task Target\n" - + "Complete the diagnosis and provide remediation advice.\n\n" - + "## Expected Output\n" - + "Return a structured diagnosis result.\n", - prompt); - } - - @Test - void renderCollapsesSectionBodyWhenFirstEffectiveLineIsStandaloneSlotWithParenthesizedSuffix() { - String prompt = renderer.render( - "## Task Target\n" - + "{{task_target}} (Required)\n\n" - + "Requirement: explain the target.\n" - + "Example: complete the diagnosis.\n\n" - + "## Expected Output\n" - + "{{expected_output}} (Optional)\n", - Map.of( - "task_target", "Complete the fault diagnosis and provide remediation advice.", - "expected_output", "Return a structured diagnosis result.")); - - assertEquals( - "## Task Target\n" - + "Complete the fault diagnosis and provide remediation advice.\n\n" - + "## Expected Output\n" - + "Return a structured diagnosis result.\n", - prompt); - } - - @Test - void renderPreservesRegularInlinePlaceholderContent() { - String prompt = renderer.render( - "## Subscription\n" - + "Please subscribe to {{topic}} incidents.\n\n" - + "## Condition\n" - + "{{condition}} (Optional)\n" - + "Requirement: describe the filter.\n", - Map.of("topic", "network", "condition", "critical only")); - - assertEquals( - "## Subscription\n" - + "Please subscribe to network incidents.\n\n" - + "## Condition\n" - + "critical only\n", - prompt); - } - - @Test - void renderRaisesWhenTemplateReferencesUnknownSlot() { - assertThrows( - TaskPromptRenderException.class, - () -> renderer.render("Site: {site}\nTime Range: {time_range}", Map.of("site", "Site A"))); - } -} 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..6eddd569 --- /dev/null +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultContentValidatorTest.java @@ -0,0 +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"); + } + } +} 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..75462cb3 --- /dev/null +++ b/a2a-t-prompt/src/test/java/net/openan/a2at/sdk/prompt/validation/DefaultSemanticValidatorTest.java @@ -0,0 +1,564 @@ +package net.openan.a2at.sdk.prompt.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.ValidationPipeline; +import net.openan.a2at.sdk.core.validation.ValidationResult; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import org.junit.jupiter.api.Test; + +class DefaultSemanticValidatorTest { + + private static final String VALID_RESPONSE = + """ + { + "semantic_verdict": true, + "errors": [], + "params": {"site": "Site A"} + } + """; + + private static final String TEMPLATE_CONTENT = "dummy template content"; + + @Test + void validatesEnUSPromptAndExtractsParams() { + RecordingClient llmClient = new RecordingClient(VALID_RESPONSE); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(llmClient, "en-US"); + + ValidationResult result = validator.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT); + + assertTrue(result.verdict()); + assertEquals(Map.of("site", "Site A"), result.params()); + assertTrue(llmClient.lastSystemContent().contains("validation")); + } + + @Test + void loadsZhCnResourcesWithoutException() { + DefaultSemanticValidator validator = new DefaultSemanticValidator(null, "zh-CN"); + + assertNotNull(validator); + } + + @Test + void throwsResourceNotFoundExceptionForMissingLanguage() { + ResourceNotFoundException exception = + assertThrows(ResourceNotFoundException.class, () -> new DefaultSemanticValidator(null, "fr-FR")); + + assertTrue(exception.resourcePath().contains("content_validation")); + assertTrue(exception.getMessage().contains("zh-CN or en-US")); + } + + @Test + void validateRetriesLlmInfrastructureErrorThroughPipeline() { + FlakyClient flakyClient = new FlakyClient(1, VALID_RESPONSE); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US"); + + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2, "en-US"); + + FilledParamData result = pipeline.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT); + + assertEquals(Map.of("site", "Site A"), result.data()); + assertEquals(2, flakyClient.invocations()); + } + + @Test + void validateExhaustsRetriesAndWrapsLlmError() { + FlakyClient flakyClient = new FlakyClient( + Integer.MAX_VALUE, + """ + { + "semantic_verdict": true, + "errors": [], + "params": {} + } + """); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(flakyClient, "en-US"); + + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3, "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> pipeline.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT)); + + assertEquals("llm.invocation_failed", exception.getCode()); + assertInstanceOf(LLMRuntimeError.class, exception.getCause()); + assertEquals(3, flakyClient.invocations()); + } + + @Test + void pipelineCarriesNullParamsIntoFilledParamData() { + // the semantic validator passed overall, but one schema slot is explicitly null: the merged + // FilledParamData must carry the key with a null value so callers scanning for blank slots can see the + // missing parameter and trigger negotiation for it + String llmJson = + "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); + + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2, "en-US"); + + FilledParamData result = pipeline.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertEquals(2, result.data().size()); + assertEquals("端口A", result.data().get("任务对象")); + assertNull(result.data().get("任务上下文")); + } + + @Test + void validatePreservesNullParamValuesAsMissingSlots() { + String llmJson = + "{\"semantic_verdict\": true, \"errors\": []," + "\"params\": {\"任务对象\": \"端口A\", \"任务上下文\": null}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertTrue(result.verdict()); + // a null param is the validator's signal that a schema slot is missing from the content; the key must stay + // present with a null value so downstream missing-slot detection (negotiation triggering) can see it + assertEquals(2, result.params().size()); + assertEquals("端口A", result.params().get("任务对象")); + assertTrue(result.params().containsKey("任务上下文")); + assertNull(result.params().get("任务上下文")); + } + + @Test + void missingVerdictKeyFailsWithInfrastructureError() { + DefaultSemanticValidator validator = + new DefaultSemanticValidator(new StubClient("{\"errors\": [], \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("semantic_verdict")); + } + + @Test + void stringVerdictValueFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("semantic_verdict")); + } + + @Test + void nonListErrorsFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": true, \"errors\": {}, \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("errors")); + } + + @Test + void malformedErrorElementFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": false, \"errors\": [\"oops\"], \"params\": {}}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("errors")); + } + + @Test + void nonMapParamsFailsWithInfrastructureError() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": []}"), "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("params")); + } + + @Test + void stringParamKeysPassTheContract() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": true, \"errors\": [], \"params\": {\"site\": \"Site A\"}}"), + "en-US"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertTrue(result.verdict()); + } + + @Test + void nonStringParamKeyFailsWithInfrastructureError() { + // JSON object keys are strings by definition, so the negative path is driven through a parser + // stub that produces a non-string key + Map rawParams = new LinkedHashMap<>(); + rawParams.put(42, "Site A"); + Map parsed = new LinkedHashMap<>(); + parsed.put("semantic_verdict", Boolean.TRUE); + parsed.put("errors", List.of()); + parsed.put("params", rawParams); + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("payload content is irrelevant"), "en-US", prompt -> parsed); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("params keys")); + } + + @Test + void contractViolationRetriesAndRecoversThroughPipeline() { + FlakyPayloadClient client = new FlakyPayloadClient( + "{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", VALID_RESPONSE); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(client, "en-US"); + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 2, "en-US"); + + FilledParamData result = pipeline.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT); + + // first attempt violates the output contract (string verdict) → retryable infra error → second attempt + // returns a compliant response and the pipeline succeeds + assertEquals(Map.of("site", "Site A"), result.data()); + assertEquals(2, client.invocations()); + } + + @Test + void contractViolationExhaustsRetriesThroughPipeline() { + FlakyPayloadClient client = + new FlakyPayloadClient("{\"semantic_verdict\": \"true\", \"errors\": [], \"params\": {}}", null); + + DefaultSemanticValidator validator = new DefaultSemanticValidator(client, "en-US"); + ValidationPipeline pipeline = new ValidationPipeline(prompt -> Map.of(), validator, 3, "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> pipeline.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "ran-energy-saving"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertEquals(3, client.invocations()); + } + + @Test + void parsesErrorsWithFactsAndRendersTheMessage() { + String llmJson = "{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"subscriptionCondition\"," + + " \"code\": \"content.param_missing\", \"facts\": {\"section_label\": \"订阅条件\"}}]," + + "\"params\": {}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "zh-CN"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertFalse(result.verdict()); + assertEquals(1, result.errors().size()); + SlotValidationError error = result.errors().get(0); + assertEquals("subscriptionCondition", error.slotName()); + assertEquals("content.param_missing", error.code()); + assertEquals("「订阅条件」未填写,请补充该参数的取值", error.message()); + assertEquals(Map.of("section_label", "订阅条件"), error.facts()); + } + + @Test + void rendersEntryFieldMissingMessageInEnglish() { + String llmJson = "{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"policyList\"," + + " \"code\": \"content.entry_field_missing\"," + + " \"facts\": {\"section_label\": \"Policy List\", \"index\": 2," + + " \"field_label\": \"Effective Date\"}}], \"params\": {}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "en-US"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertEquals(1, result.errors().size()); + SlotValidationError error = result.errors().get(0); + assertEquals("content.entry_field_missing", error.code()); + assertEquals("Entry 2 of 'Policy List' is missing required field 'Effective Date'", error.message()); + } + + @Test + void unknownErrorCodeFallsBackToContentRuleViolation() { + String llmJson = "{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"policyList\"," + + " \"code\": \"data_problem\", \"facts\": {\"section_label\": \"Policy List\"}}]," + + "\"params\": {}}"; + DefaultSemanticValidator validator = new DefaultSemanticValidator(new StubClient(llmJson), "en-US"); + + ValidationResult result = validator.validate( + "task prompt", Map.of("type", "object"), TemplateUri.of("Task-T", "network-layer"), TEMPLATE_CONTENT); + + assertEquals(1, result.errors().size()); + SlotValidationError error = result.errors().get(0); + assertEquals("content.rule_violation", error.code()); + assertEquals("The value of 'policyList' violates the validation rules.", error.message()); + assertEquals(Map.of("section_label", "policyList"), error.facts()); + } + + @Test + void missingLlmClientFailsWithLlmNotConfigured() { + DefaultSemanticValidator validator = new DefaultSemanticValidator(null, "zh-CN"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.not_configured", exception.getCode()); + assertEquals("未配置 LLM 客户端,无法执行该操作(请检查 A2AT_LLM_* 配置)", exception.getMessage()); + } + + @Test + void malformedErrorFactsFailsWithResponseInvalid() { + DefaultSemanticValidator validator = new DefaultSemanticValidator( + new StubClient("{\"semantic_verdict\": false, \"errors\": [{\"slot_name\": \"site\", \"code\":" + + " \"content.param_missing\"}], \"params\": {}}"), + "en-US"); + + ContentValidationException exception = assertThrows( + ContentValidationException.class, + () -> validator.validate( + "task prompt", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + TEMPLATE_CONTENT)); + + assertEquals("llm.response_invalid", exception.getCode()); + assertTrue(exception.getCause().getMessage().contains("facts")); + } + + @Test + void templateContentWithPlaceholderLiteralIsNotSecondOrderReplaced() { + String templateContentWithPlaceholder = "Template body with [extension_name] and [input] literals."; + RecordingClient recordingClient = new RecordingClient(VALID_RESPONSE); + DefaultSemanticValidator validator = new DefaultSemanticValidator(recordingClient, "en-US"); + + validator.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + templateContentWithPlaceholder); + + String userContent = recordingClient.userContent(); + assertTrue(userContent.contains("Template body with [extension_name] and [input] literals.")); + assertTrue(userContent.contains("Task-T")); + assertTrue(userContent.contains("Check Site A power usage")); + } + + @Test + void templateContentPlaceholderIsFilled() { + RecordingClient recordingClient = new RecordingClient(VALID_RESPONSE); + DefaultSemanticValidator validator = new DefaultSemanticValidator(recordingClient, "en-US"); + + validator.validate( + "Check Site A power usage.", + Map.of("type", "object"), + TemplateUri.of("Task-T", "network-layer"), + "## Energy Saving Template\n\nCheck the energy saving region."); + + String userContent = recordingClient.userContent(); + assertTrue(userContent.contains("## Energy Saving Template")); + assertTrue(userContent.contains("energy saving region")); + } + + private static final class RecordingClient implements LLMClient { + + private final String payload; + + private List> lastMessages; + + private RecordingClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + this.lastMessages = messages; + return new LLMResponse(payload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + + String lastSystemContent() { + for (Map message : lastMessages) { + if ("system".equals(message.get("role"))) { + return message.get("content"); + } + } + return ""; + } + + String userContent() { + for (Map message : lastMessages) { + if ("user".equals(message.get("role"))) { + return message.get("content"); + } + } + return ""; + } + } + + private static final class FlakyClient implements LLMClient { + + private final int failuresToSimulate; + + private final String successPayload; + + private final AtomicInteger counter = new AtomicInteger(0); + + private FlakyClient(int failuresToSimulate, String successPayload) { + this.failuresToSimulate = failuresToSimulate; + this.successPayload = successPayload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + int invocation = counter.incrementAndGet(); + if (invocation <= failuresToSimulate) { + throw new LLMRuntimeError("network timeout"); + } + return new LLMResponse( + successPayload, "test-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + + int invocations() { + return counter.get(); + } + } + + /** Stub LLM client returning a fixed content payload. */ + private static final class StubClient implements LLMClient { + + private final String content; + + private StubClient(String content) { + this.content = content; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse(content, "stub-llm", Map.of(), Map.of()); + } + } + + /** + * LLM client returning a malformed payload first, then a compliant one (or always malformed when success is null). + */ + private static final class FlakyPayloadClient implements LLMClient { + + private final String malformedPayload; + + private final String successPayload; + + private final AtomicInteger counter = new AtomicInteger(0); + + private FlakyPayloadClient(String malformedPayload, String successPayload) { + this.malformedPayload = malformedPayload; + this.successPayload = successPayload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + int invocation = counter.incrementAndGet(); + if (invocation == 1 || successPayload == null) { + return new LLMResponse(malformedPayload, "test-model", Map.of(), Map.of()); + } + return new LLMResponse(successPayload, "test-model", Map.of(), Map.of()); + } + + int invocations() { + return counter.get(); + } + } +} 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..a9800f0f --- /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 + + + + + + diff --git a/a2a-t-prompt/src/test/resources/prompt_resources/scenarios/en/scenarios.json b/a2a-t-prompt/src/test/resources/prompt_resources/scenarios/en/scenarios.json index e40cf4b7..348468fc 100644 --- a/a2a-t-prompt/src/test/resources/prompt_resources/scenarios/en/scenarios.json +++ b/a2a-t-prompt/src/test/resources/prompt_resources/scenarios/en/scenarios.json @@ -3,7 +3,7 @@ "default_language": "en", "scenarios": [ { - "scenario_code": "handoff_planning", + "scenario_code": "handoff-planning", "scenario_name": "Cross-Team Handoff Planning", "description": "Create a structured handoff plan for work that spans product, engineering, security, and operations ownership.", "example": "Prepare a staged migration plan for moving the billing workflow from the legacy scheduler to the event-driven orchestrator." diff --git a/a2a-t-prompt/src/test/resources/prompt_resources/slots/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/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/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/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/ClasspathPromptResourceLoader.java b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java index d5dbb14d..c00eaf3b 100644 --- a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java +++ b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoader.java @@ -3,16 +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.exception.SdkException; +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. * @@ -21,7 +30,12 @@ public final class ClasspathPromptResourceLoader { */ public String loadText(PromptResourceKey key) { String relativePath = key.relativePath(); - InputStream stream = loadResource(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); } @@ -29,18 +43,9 @@ 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); } } - private static InputStream loadResource(String relativePath) { - ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); - if (contextClassLoader != null) { - InputStream stream = contextClassLoader.getResourceAsStream(relativePath); - if (stream != null) { - return stream; - } - } - return ClasspathPromptResourceLoader.class.getClassLoader().getResourceAsStream(relativePath); - } + private record CacheKey(String relativePath, @Nullable ClassLoader contextClassLoader) {} } diff --git a/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java b/a2a-t-resources/src/main/java/net/openan/a2at/sdk/resources/PromptResourceKey.java index eac6af40..6be0e1e9 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,20 +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.model.TemplateUri; /** * Identifies one resource bundled with the SDK prompt resource tree. * - * @param category resource category such as prompts or templates - * @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/ran-energy-saving/v1} lives at + * {@code prompt_resources/templates/Task-T/network-layer/ran-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 subject, String language, String fileName) { +public record PromptResourceKey(String category, List pathSegments, String language, String fileName) { + /** + * Validates the components and defensively copies the path segment list. + * + * @throws NullPointerException if any component is null + * @throws IllegalArgumentException if any component is blank or not a simple path segment + */ public PromptResourceKey { validateSegment("category", category); - validateSegment("subject", subject); + Objects.requireNonNull(pathSegments, "pathSegments must not be null"); + for (String segment : pathSegments) { + validateSegment("path segment", segment); + } + pathSegments = List.copyOf(pathSegments); validateSegment("language", language); validateSegment("fileName", fileName); } @@ -25,22 +44,55 @@ public record PromptResourceKey(String category, String subject, String language * @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", 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 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 scenarioCode, String language, String fileName) { - return new PromptResourceKey("templates", scenarioCode, language, fileName); + public static PromptResourceKey scenario(String language, String fileName) { + return new PromptResourceKey("scenarios", List.of(), language, fileName); } /** @@ -49,10 +101,15 @@ public static PromptResourceKey template(String scenarioCode, String language, S * @return relative path under {@code prompt_resources/} */ public String relativePath() { - 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/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/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..17e5ea60 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/en-US/vocabulary.json @@ -0,0 +1,39 @@ +{ + "section.termination_reason": "Negotiation Termination Reason", + "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_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", + "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_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: ", + "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..54f3b7de --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/negotiation-vocabulary/zh-CN/vocabulary.json @@ -0,0 +1,39 @@ +{ + "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": ":" +} 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/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/content_validation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md new file mode 100644 index 00000000..5e688d21 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/system.md @@ -0,0 +1,115 @@ +You are the content validation and parameter extraction agent. Your task is to perform semantic validation and parameter extraction on the input content, and to output exactly one JSON object. + +## 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", 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: + +{ + "semantic_verdict": true or false, + "errors": [ + {"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 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: + - 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. + - 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 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 "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: +- 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/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/user.md new file mode 100644 index 00000000..0a4da1f8 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/en-US/user.md @@ -0,0 +1,7 @@ +Extension Name: [extension_name] +Input Content: [input] +Template URI: [template_uri] +Template Content: [template_content] +Parameter Schema: [schema] + +Following the system prompt, perform all validation tasks and the parameter extraction on the input content above. Output one JSON object containing all 3 required keys only. \ No newline at end of file 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 new file mode 100644 index 00000000..ec32f0d2 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/system.md @@ -0,0 +1,115 @@ +你是内容校验与参数提取代理。你的任务是对输入内容完成语义校验与参数提取,并只输出一个 JSON 对象。 + +## 输入内容的结构(如何定位实际参数值) +输入内容是由模板渲染生成的任务 prompt,结构固定: + +- 以 "## " 开头的行是模板章节标题;每个参数在模板中有同名章节。 +- 「参数章节」标题下的第一行是「参数值行」,其内容为该参数的实际值。渲染时,括号标注(如(必填))与「要求:」「举例:」等模板说明文字已被移除,「参数值行」只保留参数值本身。 +- 参数值可为多行:自标题下首个非空行起、至下一个 "## " 章节标题前的所有非空内容均为该参数的值。 +- 参数未被提供(空值)时,「参数章节」标题下没有「参数值行」(标题后紧跟空行或下一章节标题)。 +- 判定参数是否"已提供"的唯一依据:「参数章节」标题下是否存在非空「参数值行」。只要存在非空内容,该参数即为已提供;报告 content.param_missing 之前,必须先逐字确认该参数确实没有任何取值。 +- 示例:若某「参数章节」标题下的首行为 "校园专网,紧急扩容,天线调整,2026-08-01~2030-12-31",则该参数的实际值即为此行内容,该参数已提供。 + +模板正文([template_content] 字段)是原始模板的完整内容,包含格式要求、字段要求、举例等信息,是校验各项参数值的依据。 + +## 输出格式 +只输出一个 JSON 对象,包含且仅包含以下 3 个必填键;不要输出 markdown 代码块、注释或任何额外文本: + +{ + "semantic_verdict": true 或 false, + "errors": [ + {"slot_name": "字符串", "code": "字符串", "facts": {"事实键": "事实值"}} + ], + "params": {"按参数 schema 提取的参数": "取值"} +} + +- semantic_verdict:语义校验整体结论;全部校验通过时为 true,任一校验失败时为 false。 +- 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 与模板正文合并得出,某字段是否必填由模板正文裁定)。 + - 再逐条核对每个条目实际涵盖的必要信息并与清单比对。 + - 条目缺少任何必要信息即为确定性的缺失,必须报告 content.entry_field_missing;即使无法确定缺少的具体是哪一项,也必须报告;不得以任何理由放行信息不全的条目。 +6. 校验依据与分工: + - 模板正文承载业务规则(各操作类型的必填字段、可修改性、操作范围);参数 schema 承载取值与格式约束。二者各司其职,均为校验依据。 + - 参数 schema 的字段集与描述性说明不参与字段存在性与必填性判定: + - schema 未声明某字段,不豁免模板正文对该字段的要求。 + - schema 的描述性说明与模板正文要求的必填字段相抵触时,不改变模板正文的要求。 + - schema 未复述的业务规则照常生效。 + - 此豁免仅针对字段存在性与必填性;参数 schema 的格式与取值约束仍照常参与校验。 +7. 校验边界(区分两类检查): + - 确定性检查(必须严格执行,不得以"不确定/可能"为由放行): + - 必填字段是否存在。 + - 取值是否在约束列举的范围内。 + - 是否为约束明确禁止的操作(含模板正文声明不支持的修改/操作意图)。 + - 标识符格式是否合法。 + - 值是否为无意义字符。 + - 日期区间是否倒置(起始日期晚于结束日期)。 + - 格式变体检查(默认通过,仅当明确违反约束时才报错): + - 日期写法、分隔符风格等格式变体,若约束未明确排除该变体,一律通过。 + - 只有当值明确违反约束列举的规则时才报错(如月份 13 不存在、约束要求完整区间而只给了单个日期)。 +8. 可选参数:参数 schema 或约束标注为可选的参数,其值为空不构成错误。 + +## 参数提取任务 +- 参数提取的唯一来源是各「参数章节」的「参数值行」(渲染后的实际参数值);不要从模板正文中提取。 +- 按参数 schema 定义的结构从「参数值行」中提取参数并填充 params 对象;值行中未提供的信息输出 null 或空集合(按 schema 的类型约定)。 +- params 的属性名与结构必须遵循参数 schema;无法从输入内容中提取到的属性输出 null。 +- params 的键集必须与参数 schema 声明的属性完全一致:不能多、不能少、不能改名(顶层属性与数组条目内的字段同此规则)。 +- 提取不到的键不得省略,值输出 null 或按 schema 的类型约定输出空集合。 +- 值为空(含空数组、「参数章节」无「参数值行」)时,键名仍须取参数 schema 声明的属性名。 +- 参数提取结果不影响 semantic_verdict;semantic_verdict 只由校验任务决定。 + +## slot_name 与 code 规范 +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/content_validation/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/user.md new file mode 100644 index 00000000..ae6b0bfd --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/content_validation/zh-CN/user.md @@ -0,0 +1,7 @@ +扩展名称:[extension_name] +输入内容:[input] +模板标识:[template_uri] +模板正文:[template_content] +参数模式:[schema] + +请按系统提示词要求,对上述输入内容完成全部校验任务与参数提取,只输出包含全部 3 个必填键的 JSON 对象。 \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/system.md index 6451917a..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 @@ -1 +1,110 @@ -Feasibility negotiation started by the {role} side. +You are a feasibility negotiation content extraction agent. Your task is to extract the structured feasibility negotiation content JSON from a natural-language input text, according to the given negotiation phase, for downstream template rendering. + +## Output Format +Output exactly one JSON object. Do not output markdown code fences, comments, or any additional text. + +## Phase and Output Structure +The negotiation phase of the input text is given by the phase field in the user prompt: + +1. Propose phase: extract the negotiation summary, the message category (action), the matching conditional content, and the feasible-evaluation confirmation request. Output structure: + +{ + "feasibility_negotiation_description": "feasibility negotiation summary, string", + "action": "REQUEST_FEASIBILITY_EVALUATION or PROPOSE_ALTERNATIVE_ON_FAILURE", + "contents_to_evaluate": [ + {"name": "entry name", "value": "entry content"} + ], + "infeasibility_details_and_proposal": [ + {"name": "entry name", "value": "entry content"} + ], + "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: + +{ + "conclusion": "Accept or Reject", + "feasibility_summary": "feasibility result confirmation, string" +} + +## Field Rules +- 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, 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. + - 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. 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 (initiating a feasibility assessment) + +{ + "feasibility_negotiation_description": "Request evaluating the feasibility of maintaining a 5Mbps guaranteed-rate target during the power-outage protection scenario.", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": [ + {"name": "evaluation target", "value": "rate guarantee for key users during the 8-hour outage"} + ], + "infeasibility_details_and_proposal": null, + "feasibility_confirm_request": null +} + +### Example 2: propose phase (infeasible, proposing an alternative) + +{ + "feasibility_negotiation_description": "The 5Mbps guaranteed-rate target is infeasible in the power-outage protection scenario; a reduced target is proposed.", + "action": "PROPOSE_ALTERNATIVE_ON_FAILURE", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": [ + {"name": "infeasibility reason", "value": "the battery can only sustain a 2Mbps guarantee for 8 hours"}, + {"name": "alternative proposal", "value": "reduce the guaranteed-rate target to 2Mbps during the outage"} + ], + "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 5: ending phase (accept) + +{ + "conclusion": "Accept", + "feasibility_summary": "Agree to reduce the guaranteed-rate target during the outage from 5Mbps to 2Mbps; this feasibility negotiation is confirmed as closed." +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/user.md index 16f27d30..9b2545d6 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/user.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/en-US/user.md @@ -1,4 +1,6 @@ -Status={status} -Round={round} -Content={content_text} -Facts={facts_json} +Negotiation phase: [phase] + +Input text: +[input] + +Following the output format and field rules in the system prompt, extract the feasibility negotiation content for the current phase from the input text above. Output the JSON object only. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/system.md index dbe38a65..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 @@ -1 +1,110 @@ -由{role}方发起可行性协商。 +你是可行性协商(feasibility negotiation)内容提取代理。你的任务是根据给定的协商阶段,从自然语言输入文本中提取可行性协商的结构化内容 JSON,供后续模板渲染使用。 + +## 输出格式 +只输出一个 JSON 对象,不要输出 markdown 代码块、注释或任何额外文本。 + +## 阶段与输出结构 +输入文本所处的协商阶段由用户提示词中的阶段字段给出: + +1. 发起阶段(propose):提取可行性协商概述、消息类别(action)与对应的条件内容、评估可行时的确认请求,输出结构: + +{ + "feasibility_negotiation_description": "可行性协商概述,字符串", + "action": "REQUEST_FEASIBILITY_EVALUATION 或 PROPOSE_ALTERNATIVE_ON_FAILURE", + "contents_to_evaluate": [ + {"name": "条目名称", "value": "条目内容"} + ], + "infeasibility_details_and_proposal": [ + {"name": "条目名称", "value": "条目内容"} + ], + "feasibility_confirm_request": "评估可行时的确认请求,字符串或 null" +} + +2. 结论阶段(accept / reject / accept-reject):提取协商结论与可行性评估结果确认,输出结构: + +{ + "conclusion": "Accept 或 Reject", + "feasibility_summary": "可行性评估结果确认,字符串" +} + +## 字段规则 +- feasibility_negotiation_description:发起阶段必填。概括本次可行性协商的目的与消息类别。 +- action:发起阶段必填枚举,只能取以下两个值之一: + - "REQUEST_FEASIBILITY_EVALUATION":请求对方评估某些事项的可行性,或本方已完成评估且结论为可行; + - "PROPOSE_ALTERNATIVE_ON_FAILURE":已知目标不可行时,说明不可行详情并提出替代方案。 + - 消息类别为"评估可行并请求确认"时,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",提取 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:发起阶段(发起可行性评估) + +{ + "feasibility_negotiation_description": "请求评估停电保障场景下维持5Mbps速率保障目标的可行性。", + "action": "REQUEST_FEASIBILITY_EVALUATION", + "contents_to_evaluate": [ + {"name": "评估对象", "value": "停电8小时期间核心用户的速率保障"} + ], + "infeasibility_details_and_proposal": null, + "feasibility_confirm_request": null +} + +### 示例2:发起阶段(不可行并提出替代方案) + +{ + "feasibility_negotiation_description": "停电保障场景下5Mbps速率保障目标不可行,提出下调方案。", + "action": "PROPOSE_ALTERNATIVE_ON_FAILURE", + "contents_to_evaluate": null, + "infeasibility_details_and_proposal": [ + {"name": "不可行原因", "value": "蓄电池仅能支撑8小时2Mbps的保障能力"}, + {"name": "替代提案", "value": "停电期间将速率保障目标下调至2Mbps"} + ], + "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": "评估方案可行,是否同意按照此方案继续执行?" +} + +### 示例5:结论阶段(accept) + +{ + "conclusion": "Accept", + "feasibility_summary": "同意将停电期间速率保障目标由5Mbps下调至2Mbps,本次可行性协商确认结束。" +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/user.md index dd2fb94d..eab757d2 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/user.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/feasibility_negotiation/zh-CN/user.md @@ -1,4 +1,6 @@ -状态={status} -轮次={round} -内容={content_text} -事实={facts_json} +协商阶段:[phase] + +输入文本: +[input] + +请按系统提示词的输出格式与字段规则,从上述输入文本中提取当前阶段的可行性协商内容,只输出 JSON 对象本身。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/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/main/resources/prompt_resources/prompts/information_negotiation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/system.md index a60c7eaf..f11e27c2 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/system.md @@ -1 +1,69 @@ -Information negotiation started by the {role} side. +You are an information negotiation content extraction agent. Your task is to extract the structured information negotiation content JSON from a natural-language input text, according to the given negotiation phase, for downstream template rendering. + +## Output Format +Output exactly one JSON object. Do not output markdown code fences, comments, or any additional text. + +## Phase and Output Structure +The negotiation phase of the input text is given by the phase field in the user prompt: + +1. Propose phase: extract the information items the initiating party requests from the peer, plus the relationship between the items. Output structure: + +{ + "items": [ + {"name": "information item name", "value": "meaning, format, or example of the item"} + ], + "relationship": "relationship between the missing items, or null" +} + +2. Ending phase (accept / reject / accept-reject): extract the negotiation conclusion and its result content. Output structure: + +{ + "conclusion": "Accept or Reject", + "items": [ + {"name": "information item name", "value": "value of the item"} + ] +} + +## Field Rules +- items: required array. Each element is an object with exactly two keys, name and value; name is the information item name (string), value is the item's value, meaning, format, or example (string, may be null). The propose phase may output an empty array when the input contains no information items; the ending phase must contain at least one item. +- relationship: output in the propose phase only. Either a string or null; when the input does not state a relationship between the missing items, it must be null. Do not fabricate. +- conclusion: output in the ending phase only. Its value must be either "Accept" or "Reject" and must faithfully reflect the conclusion expressed by the input text; never output "Abort". + +## Extraction Principles +1. Extract only content explicitly expressed in the input text; do not fill in values from general knowledge or guess. +2. In the propose phase, expressions such as "we still need / please provide / missing" introduce the information items to obtain; qualifying statements that follow (format, example, meaning) become the value of that item. +3. In the ending phase, when conclusion is Accept, map each answer to its requested item. When conclusion is Reject, output one item for every requested information item explicitly identified as unavailable: use that information item's name as name and its concrete non-provision reason as value. If one reason applies to multiple items, repeat it in each corresponding item; do not merge them into one aggregate "Rejection reason" item. A Reject conclusion must never produce an empty items array. +4. Descriptions of dependencies, exclusivity, or composition between information items map to relationship. +5. When uncertain: output null for optional fields. The propose-phase items array may be empty. The ending-phase items array must not be empty; when Reject has no explicit reason, output one item with a null value for each unavailable information item explicitly named in the input, and do not fabricate reasons or add items that are not named. + +## Output Examples + +### Example 1: propose phase + +{ + "items": [ + {"name": "fault occurrence time", "value": "time point accurate to the minute"}, + {"name": "affected cell identifier", "value": "CGI or cell name"} + ], + "relationship": "the fault occurrence time and the affected cell identifier must correspond cell by cell" +} + +### Example 2: ending phase (accept) + +{ + "conclusion": "Accept", + "items": [ + {"name": "fault occurrence time", "value": "2026-08-19 10:30"}, + {"name": "affected cell identifier", "value": "CGI: 460-00-xxx-yyy"} + ] +} + +### Example 3: ending phase (reject, itemized) + +{ + "conclusion": "Reject", + "items": [ + {"name": "access-port name", "value": "The resource query service is under maintenance and cannot provide it."}, + {"name": "complaint category", "value": "The resource query service is under maintenance and cannot provide it."} + ] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/user.md index 16f27d30..032d715b 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/user.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/en-US/user.md @@ -1,4 +1,6 @@ -Status={status} -Round={round} -Content={content_text} -Facts={facts_json} +Negotiation phase: [phase] + +Input text: +[input] + +Following the output format and field rules in the system prompt, extract the information negotiation content for the current phase from the input text above. Output the JSON object only. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/system.md index 72e883e0..3fb6f636 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/system.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/system.md @@ -1 +1,69 @@ -由{role}方发起信息协商。 +你是信息协商(information negotiation)内容提取代理。你的任务是根据给定的协商阶段,从自然语言输入文本中提取信息协商的结构化内容 JSON,供后续模板渲染使用。 + +## 输出格式 +只输出一个 JSON 对象,不要输出 markdown 代码块、注释或任何额外文本。 + +## 阶段与输出结构 +输入文本所处的协商阶段由用户提示词中的阶段字段给出: + +1. 发起阶段(propose):提取发起方请求对方提供的信息项以及信息项之间的关系,输出结构: + +{ + "items": [ + {"name": "信息项名称", "value": "信息项含义、格式或样例"} + ], + "relationship": "缺失信息项之间的关系,或 null" +} + +2. 结论阶段(accept / reject / accept-reject):提取协商结论与对应的结果内容,输出结构: + +{ + "conclusion": "Accept 或 Reject", + "items": [ + {"name": "信息项名称", "value": "信息项取值"} + ] +} + +## 字段规则 +- items:必填数组。每个元素是一个恰好包含 name 与 value 两个键的对象;name 为信息项名称(字符串),value 为该信息项的取值、含义、格式或样例(字符串,可为 null)。发起阶段输入中没有信息项时可输出空数组;结论阶段必须至少输出一个元素。 +- relationship:仅发起阶段输出。为字符串或 null;输入未说明缺失信息项之间的关系时必须为 null,不要编造。 +- conclusion:仅结论阶段输出。取值只能为 "Accept" 或 "Reject",必须忠实于输入文本表达的结论;不得输出 "Abort"。 + +## 提取原则 +1. 只提取输入文本中明确表达的内容,不要基于常识补值或猜测。 +2. 发起阶段中,"还需要 / 请提供 / 缺少"等表述引导的是待获取的信息项;其后对格式、样例、含义的限定说明作为该信息项的 value。 +3. 结论阶段中,若结论为 Accept,对所请求信息的答复逐项映射为 items;若结论为 Reject,必须按输入中明确提及的每个无法提供的信息项逐项输出,name 使用该信息项名称,value 使用该信息项无法提供的具体原因。相同原因影响多个信息项时,必须在每个对应 item 中重复该原因;不得把多个信息项合并为一个“拒绝原因” item。拒绝结论不得输出空 items。 +4. 信息项之间的依赖、互斥、组合等关系描述映射为 relationship。 +5. 拿不准时:可选字段输出 null;发起阶段的 items 可保留空数组。结论阶段不得输出空 items;Reject 未说明具体原因时,对输入中明确点名的每个无法提供的信息项输出对应 item,并将 value 设为 null,不要编造原因或新增信息项。 + +## 输出示例 + +### 示例1:发起阶段(propose) + +{ + "items": [ + {"name": "故障发生时间", "value": "精确到分钟的时间点"}, + {"name": "受影响小区标识", "value": "CGI 或小区名称"} + ], + "relationship": "故障发生时间与受影响小区标识需逐小区对应" +} + +### 示例2:结论阶段(accept) + +{ + "conclusion": "Accept", + "items": [ + {"name": "故障发生时间", "value": "2026-08-19 10:30"}, + {"name": "受影响小区标识", "value": "CGI: 460-00-xxx-yyy"} + ] +} + +### 示例3:结论阶段(reject,逐项说明) + +{ + "conclusion": "Reject", + "items": [ + {"name": "接入端口名称", "value": "资源查询服务正在检修,暂时无法提供"}, + {"name": "投诉分类", "value": "资源查询服务正在检修,暂时无法提供"} + ] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/user.md index dd2fb94d..711f9248 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/user.md +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/information_negotiation/zh-CN/user.md @@ -1,4 +1,6 @@ -状态={status} -轮次={round} -内容={content_text} -事实={facts_json} +协商阶段:[phase] + +输入文本: +[input] + +请按系统提示词的输出格式与字段规则,从上述输入文本中提取当前阶段的信息协商内容,只输出 JSON 对象本身。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md new file mode 100644 index 00000000..e74d7889 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/system.md @@ -0,0 +1,162 @@ +You are the semantic validation and parameter extraction agent for Negotiation-T negotiation messages. Your task is to perform semantic validation, structural semantics validation, template consistency validation, and parameter extraction on a negotiation message, and to output exactly one JSON object. + +## Output Format +Output exactly one JSON object containing exactly the following 4 required keys; do not output markdown code fences, comments, or any additional text: + +{ + "semantic_verdict": true or false, + "negotiation_type": "information" or "target" or "feasibility" or null, + "errors": [ + {"slot_name": "string", "code": "string", "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 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 +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 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 +- Extract parameters from the message content per the parameter schema given in the user prompt and fill the params object. +- The property names and structure of params must follow the parameter schema; output null for properties that cannot be extracted from the message. +- When the message conclusion is Reject, each parameter schema field's value is the reason of non-provision stated for that field in the message, not null - the reason is what the field transports in this negotiation round. +- When the declared negotiation phase is propose, each parameter schema field's value is the full expectation text stated for that field in the message (meaning, format requirement, or sample), neither null nor the sample alone - the expectation is what the field transports in a request message. Keep the sample markers such as "如"/"e.g." and never treat a sample as the field's supplied value. +- The parameter extraction result does not affect semantic_verdict; semantic_verdict is decided solely by validation tasks 1-6. + +## slot_name Convention +The slot_name of semantic and structural semantics errors must use the following language-neutral canonical keys, chosen by the message section at fault: +- section.termination_reason: Negotiation Termination Reason +- section.info_static: Information Negotiation +- section.info_items: Required Information Items +- section.info_conclusion: Information Negotiation Result +- section.info_result_content: Information Negotiation Result Content +- section.target: Target Negotiation +- section.target_intent: Intent Understanding 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 + +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). + +## 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. + +### Example 1: validation passed + +{ + "semantic_verdict": true, + "negotiation_type": "feasibility", + "errors": [], + "params": { + "confirmed_rate_mbps": 2 + } +} + +### Example 2: validation failed (conclusion and result content mismatch) + +{ + "semantic_verdict": false, + "negotiation_type": "target", + "errors": [ + { + "slot_name": "section.target_result_content", + "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/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/user.md new file mode 100644 index 00000000..d330a65e --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/en-US/user.md @@ -0,0 +1,12 @@ +Negotiation phase: [phase] +Declared negotiation type: [negotiation_type] +Declared template identifier: [template_uri] +Template content: [template_content] + +Parameter schema: +[schema] + +Negotiation message to validate: +[input] + +Following the system prompt, perform all validation tasks and the parameter extraction on the negotiation message above. Output one JSON object containing all 4 required keys only. \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md new file mode 100644 index 00000000..2f7f8986 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/system.md @@ -0,0 +1,162 @@ +你是 Negotiation-T 协商报文的语义校验与参数提取代理。你的任务是对一份协商报文完成语义校验、结构语义校验、模板一致性校验与参数提取,并只输出一个 JSON 对象。 + +## 输出格式 +只输出一个 JSON 对象,包含且仅包含以下 4 个必填键;不要输出 markdown 代码块、注释或任何额外文本: + +{ + "semantic_verdict": true 或 false, + "negotiation_type": "information" 或 "target" 或 "feasibility" 或 null, + "errors": [ + {"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、facts 三个键的对象;semantic_verdict 为 true 时必须为空数组;facts 的键与取值要求见「code 与 facts 规范」;不要输出 message 键。 +- params:按参数 schema 从报文中提取的参数对象;无参数可提取时输出空对象 {}。 + +## 校验任务 +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 模板,均记模板一致性错误。 + +## 参数提取任务 +- 按用户提示词中给出的参数 schema,从报文内容中提取参数并填充 params 对象。 +- params 的属性名与结构必须遵循参数 schema;无法从报文中提取到的属性输出 null。 +- 当报文结论为 Reject 时,参数 schema 中各字段的取值为该字段在报文中说明的无法提供的原因文本,而不是 null——原因就是该字段在本轮协商中传递的内容。 +- 当声明的协商阶段为发起(propose)时,参数 schema 中各字段的取值为该字段在报文中说明的完整期望内容原文(含义、格式要求或样例),而不是 null,也不是仅截取样例部分——期望说明就是该字段在请求报文中传递的内容;保留"如""取值"等说明标记,不得将样例当作该字段已提供的真实值。 +- 参数提取结果不影响 semantic_verdict;semantic_verdict 只由校验任务 1-6 决定。 + +## slot_name 规范 +语义错误与结构语义错误的 slot_name 必须使用以下语言无关的规范键,按出错的报文板块选择: +- section.termination_reason:协商终止原因(Negotiation Termination Reason) +- section.info_static:信息协商(Information Negotiation) +- section.info_items:所需信息项(Required Information Items) +- section.info_conclusion:信息协商结果(Information Negotiation Result) +- section.info_result_content:信息协商结果内容(Information Negotiation Result Content) +- section.target:目标协商(Target Negotiation) +- section.target_intent:意图理解陈述(Intent Understanding 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_name 使用蕴含出错板块的规范键(例如可行性报文类型不符时使用 section.feasibility)。 + +## 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 为准。 + +### 示例1:校验通过 + +{ + "semantic_verdict": true, + "negotiation_type": "feasibility", + "errors": [], + "params": { + "confirmed_rate_mbps": 2 + } +} + +### 示例2:校验不通过(结论与结果内容不匹配) + +{ + "semantic_verdict": false, + "negotiation_type": "target", + "errors": [ + { + "slot_name": "section.target_result_content", + "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/negotiation_semantic_validation/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/user.md new file mode 100644 index 00000000..e238d15a --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/negotiation_semantic_validation/zh-CN/user.md @@ -0,0 +1,12 @@ +协商阶段:[phase] +声明的协商类型:[negotiation_type] +声明的模板标识:[template_uri] +模板正文:[template_content] + +参数 schema: +[schema] + +待校验的协商报文: +[input] + +请按系统提示词要求,对上述协商报文完成全部校验任务与参数提取,只输出包含全部 4 个必填键的 JSON 对象。 \ No newline at end of file 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 b6b975ea..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,35 +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 value_constraint (value set to null) +Report errors only in the `slot_errors` array, using only the following two codes: -### 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" +- **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"}}`. -## Extraction Strategy -1. Analyze the user input to identify explicit slot values -2. Cross-reference with slot schema for value_constraint validation -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\"]") +- **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 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 b677aa2c..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,44 +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` 语义明确对应的字段标签,并常见于“字段名 + 为 / 是 / : / : / 包括 / 包含”等结构,以及分段式、列表式字段标签。 +1. 仅提取"用户明确要求写入任务"的约束内容。 +2. 背景事实、历史说明、示例文本、模板提示、语言风格要求、元任务说明(如翻译/改写/解释)默认不提取。用户明确声明"某字段先不填/暂不需要/后面再补/待定"等不提供该字段值的表述,属于对该字段的缺省声明而非字段值本身,不应作为该字段的值提取。 +3. 若某值位于否定或排除语义中(如"不要/不是/非/排除"),不得作为正向参数值提取。 +4. 若输入中出现显式字段锚点,应优先按字段锚点后的内容映射到对应参数。 +5. 显式字段锚点可以表现为参数名称本身,或与参数 `description` 语义明确对应的字段标签,并常见于"字段名 + 为 / 是 / : / : / 包括 / 包含"等结构,以及分段式、列表式字段标签。 6. 对显式字段锚点后的值,即使它与场景默认语义、模板固定文案或其他上下文重复,也不要省略。 -7. 仅当某内容与 slot 的 `description` 或 `value_constraint` 明确对应时才提取。 -8. 同一 slot 下若有多个明确并列约束,应尽量完整保留。 +7. 仅当某内容与参数的 `description` 或 `x-a2at-value-constraint` 明确对应时才提取。 +8. 同一参数下若有多个明确并列约束,应尽量完整保留。 9. 拿不准时返回 `null`,不要基于常识补值或猜测。 -10. 若同一 slot 的信息分散在多个子句中,且这些子句都在表达用户要写入任务的正向约束,应合并保留,不要只保留最后出现的一项。 -11. “另外/也/同时/并且/补上/纳入条件/再加上”默认表示**追加约束**;只有出现“改成/替换为/仅保留/除了…都不要”等明确替换或排除信号时,才能覆盖之前已提到的值。 -12. 当输入同时包含“已有内容/当前对象/前文条件”和“新增条件/补充要求/限制范围”时,若都属于用户明确要求写入任务的正向约束,应该合并保留,而不是默认只保留最后提到的一部分。 +10. 若同一参数的信息分散在多个子句中,且这些子句都在表达用户要写入任务的正向约束,应合并保留,不要只保留最后出现的一项。 +11. "另外/也/同时/并且/补上/纳入条件/再加上"默认表示**追加约束**;只有出现"改成/替换为/仅保留/除了…都不要"等明确替换或排除信号时,才能覆盖之前已提到的值。 +12. 当输入同时包含"已有内容/当前对象/前文条件"和"新增条件/补充要求/限制范围"时,若都属于用户明确要求写入任务的正向约束,应该合并保留,而不是默认只保留最后提到的一部分。 +13. 若参数通过 `x-a2at-value-constraint` 定义了「封闭取值范围」(使用"取值范围:"或"Allowed values:"等封闭列举格式),严格按以下顺序判定且禁止跳步或绕过: + (a) 输入中出现某合法取值本身或其简写(如取值词是某完整合法取值的简写时)、且处于要求写入任务的指令语境时,直接提取该合法取值;若该字面量出现在咨询、疑问、元任务(如"如何新增…""新增…的流程是什么""翻译:…")等非指令语境中,则本条不适用,按规则 2 处理(值设为 null); + (b) 输入词是值约束认可的同义对清单中的词时,映射为对应合法取值; + (c) (a) 和 (b) 都不成立时,必须将值设为 null——即使你能为输入词构想出与某合法取值的语义联系(如"导出"与"查询"、"复制"与"查询"),也一律禁止映射;不得以"本质上是一种…意图"等推理绕过本判定。 +14. 当输入包含与参数相关的信息但格式不完整、或不满足 `x-a2at-value-constraint` 中的全部必填字段要求时,仍应提取输入中已有的相关内容,不要因部分缺失而整体返回 null。本条仅适用于未定义「封闭取值范围」的参数;定义了「封闭取值范围」的参数一律按规则 13 判定。提取时应保留该参数相关内容的完整语义(包括修饰语、限定语、修改意图等),不得只截取其中一部分(如只取标识而丢弃同句中的修改内容);当字段信息分散在句首/前缀与列表主体两处时(如句首提到某字段、列表中给出其余字段),应合并为完整条目。 +15. 提取时保留关键信息的完整语义,不必逐字复制原文措辞或分隔符形态;信息分散多处需要合并时按语义拼接。字段缺失时直接省略该字段及其分隔符,不得保留空占位、尾部分隔符或为凑数补默认值。 +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 的 value_constraint(值设为 null) +仅在 `slot_errors` 数组中报告错误,只使用以下两个错误码: -### 必填 vs 可选 Slot -- **必填 slot 缺失**:值设为 null,添加错误 code="missing_input" -- **可选 slot 缺失**:值设为 null,无需错误条目 -- **值违反约束**:值设为 null,添加错误 code="invalid_value" +- **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": "导出"}}`。 + +### 必填与可选参数 +- 必填参数缺失:值设为 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 0c62c6cc..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,11 +41,48 @@ "slot_errors": [ { "slot_name": "责任人", - "code": "missing_input", - "message": "必填字段\"责任人\"未从输入中提取到" + "code": "slot.not_provided", + "facts": { + "slot_label": "责任人" + } } ] } ``` +### 示例4:授权策略管理场景(新增) +```json +{ + "slots": { + "授权策略的操作类型": "新增授权策略", + "动网操作的授权策略列表": "校园专网,紧急扩容,天线调整,2026-08-01~2030-12-31" + }, + "slot_errors": [] +} +``` +说明:输入"帮我新增一个授权策略,业务场景是校园专网,处置类型是紧急扩容,做个天线调整,有效期从2026年8月1日到2030年12月31日"—— +- 操作类型:输入含"新增"→映射为"新增授权策略"(规则13a); +- 策略列表:提取四个字段的值,按值约束约定的顺序与分隔符组织;日期按约束格式归一(规则18)。 + +### 示例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/prompts/target_negotiation/en-US/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/system.md new file mode 100644 index 00000000..826595d1 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/system.md @@ -0,0 +1,97 @@ +You are a target negotiation content extraction agent. Your task is to extract the structured target negotiation content JSON from a natural-language input text, according to the given negotiation phase, for downstream template rendering. + +## Output Format +Output exactly one JSON object. Do not output markdown code fences, comments, or any additional text. + +## Phase and Output Structure +The negotiation phase of the input text is given by the phase field in the user prompt: + +1. Propose phase: extract the negotiation summary, intent understanding, alignment and clarification, clarification required, and the target clarification confirmation request. Output structure: + +{ + "target_negotiation_description": "target negotiation summary, string", + "intent_understanding": [ + {"name": "entry name", "value": "entry content"} + ], + "alignment_and_clarification": [ + {"name": "entry name", "value": "entry content"} + ], + "request_for_clarification": [ + {"name": "entry name", "value": "entry content"} + ], + "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: + +{ + "conclusion": "Accept or Reject", + "confirmed_intent": "confirmed target or intent, string or null", + "failure_reason": "reason for not reaching agreement, string or null" +} + +## Field Rules +- target_negotiation_description: required in the propose phase. A paragraph summarizing the purpose and message 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. 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. + +## Output Examples + +### Example 1: propose phase + +{ + "target_negotiation_description": "Request adjusting the energy-saving target from 30% to 20% while keeping the guaranteed rate no lower than 50Mbps.", + "intent_understanding": [ + {"name": "initiator understanding", "value": "the peer wants to reduce the energy-saving intensity while keeping the experience lossless"} + ], + "alignment_and_clarification": null, + "request_for_clarification": [ + {"name": "guaranteed rate floor", "value": "whether a guaranteed rate floor of 50Mbps is acceptable"} + ], + "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 3: ending phase (accept) + +{ + "conclusion": "Accept", + "confirmed_intent": "Both sides confirm adjusting the energy-saving target to 20% with the guaranteed rate no lower than 50Mbps.", + "failure_reason": null +} + +### Example 4: ending phase (reject) + +{ + "conclusion": "Reject", + "confirmed_intent": null, + "failure_reason": "The peer insists on the 30% energy-saving target; no agreement was reached on the guaranteed rate floor." +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/user.md new file mode 100644 index 00000000..4bb8ad2b --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/en-US/user.md @@ -0,0 +1,6 @@ +Negotiation phase: [phase] + +Input text: +[input] + +Following the output format and field rules in the system prompt, extract the target negotiation content for the current phase from the input text above. Output the JSON object only. diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/system.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/system.md new file mode 100644 index 00000000..941fa36e --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/system.md @@ -0,0 +1,97 @@ +你是目标协商(target negotiation)内容提取代理。你的任务是根据给定的协商阶段,从自然语言输入文本中提取目标协商的结构化内容 JSON,供后续模板渲染使用。 + +## 输出格式 +只输出一个 JSON 对象,不要输出 markdown 代码块、注释或任何额外文本。 + +## 阶段与输出结构 +输入文本所处的协商阶段由用户提示词中的阶段字段给出: + +1. 发起阶段(propose):提取目标协商概述、意图理解、理解对齐与疑问澄清、待澄清内容、目标澄清后的确认请求,输出结构: + +{ + "target_negotiation_description": "目标协商概述,字符串", + "intent_understanding": [ + {"name": "条目名称", "value": "条目内容"} + ], + "alignment_and_clarification": [ + {"name": "条目名称", "value": "条目内容"} + ], + "request_for_clarification": [ + {"name": "条目名称", "value": "条目内容"} + ], + "target_confirm_request": "目标澄清后的确认请求,字符串或 null" +} + +2. 结论阶段(accept / reject / accept-reject):提取协商结论与协商结果内容,输出结构: + +{ + "conclusion": "Accept 或 Reject", + "confirmed_intent": "确认后的目标或意图,字符串或 null", + "failure_reason": "未达成一致的原因,字符串或 null" +} + +## 字段规则 +- target_negotiation_description:发起阶段必填。概括本次目标协商的目的与消息类别的一段文字。 +- intent_understanding / alignment_and_clarification / request_for_clarification:发起阶段可选,取值为条目数组或 null。 + - intent_understanding:发起方对对方意图的理解陈述,通常在首轮报文中出现。 + - alignment_and_clarification:双方理解对齐情况与已澄清、待说明的事项,通常在后续轮次报文中出现。 + - request_for_clarification:需要对方澄清的具体问题;输入未提出澄清问题时为 null 或空数组。 + - 每个条目是一个恰好包含 name 与 value 两个键的对象;value 可为 null。 +- 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;target_confirm_request 为 null。 + - "目标已澄清并请求对方确认":输入表明目标澄清完成,请求对方确认是否按此目标继续执行。此时提取 target_confirm_request,三个理解类字段必须全为 null。 +3. 输入未表达某可选板块的内容时,该字段输出 null,不要编造条目。 +4. 结论阶段按 conclusion 的取值决定 confirmed_intent 与 failure_reason 的取舍,未用到的一侧必须为 null。 +5. 同一板块下多个并列要点应完整保留为多个条目,不要只保留最后一项。 + +## 输出示例 + +### 示例1:发起阶段(propose) + +{ + "target_negotiation_description": "请求将节能目标由30%调整为20%,同时保持速率保障不低于50Mbps。", + "intent_understanding": [ + {"name": "发起方理解", "value": "对方希望在体验无损的前提下降低节能力度"} + ], + "alignment_and_clarification": null, + "request_for_clarification": [ + {"name": "速率保障下限", "value": "50Mbps 的速率保障下限是否可接受"} + ], + "target_confirm_request": null +} + +### 示例2:发起阶段(propose,目标已澄清并请求对方确认) + +{ + "target_negotiation_description": "任务目标澄清完成,请求对方确认是否按此目标继续执行。", + "intent_understanding": null, + "alignment_and_clarification": null, + "request_for_clarification": null, + "target_confirm_request": "目标已经澄清,是否同意按照此目标继续执行?" +} + +### 示例3:结论阶段(accept) + +{ + "conclusion": "Accept", + "confirmed_intent": "双方确认将节能目标调整为20%,速率保障不低于50Mbps。", + "failure_reason": null +} + +### 示例4:结论阶段(reject) + +{ + "conclusion": "Reject", + "confirmed_intent": null, + "failure_reason": "对方坚持30%的节能目标,双方未就速率保障下限达成一致。" +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/user.md b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/user.md new file mode 100644 index 00000000..efca2cd0 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/prompts/target_negotiation/zh-CN/user.md @@ -0,0 +1,6 @@ +协商阶段:[phase] + +输入文本: +[input] + +请按系统提示词的输出格式与字段规则,从上述输入文本中提取当前阶段的目标协商内容,只输出 JSON 对象本身。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json new file mode 100644 index 00000000..178f8f93 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/en-US/scenarios.json @@ -0,0 +1,28 @@ +{ + "scenarios": [ + { + "scenario_code": "subscribe-incident", + "scenario_name": "Incident Event Subscription", + "description": "Used to generate an A2A-T task request for subscribing to Incident events in a telecom network management system, describing the subscription target, conditions, and reporting method for network-side faults, alarms, or Incident notifications.", + "example": "Help me generate an Incident subscription task: subscribe to fiber break and pigtail fault for base station equipment, only critical and high severity, reported via DataPart." + }, + { + "scenario_code": "ran-energy-saving", + "scenario_name": "Energy Efficiency Optimization Task", + "description": "Used to generate an A2A-T task request for an energy efficiency optimization task in a telecom network management system.", + "example": "Achieve a 30% energy consumption reduction in the Songshanhu area while ensuring the rate guarantee is no less than 50Mbps." + }, + { + "scenario_code": "private-line-complaint", + "scenario_name": "Private Line Complaint", + "description": "Used to generate an A2A-T task request for network-side fault root cause diagnosis in a private line complaint scenario in a telecom network management system.", + "example": "Please confirm the root cause of fault fault-id-1-328-20260510-58489 that occurred on the Guangdong enterprise private line at 2026-05-10T18:25:30Z, and provide repair suggestions." + }, + { + "scenario_code": "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 d003c924..bccf593a 100644 --- a/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json +++ b/a2a-t-resources/src/main/resources/prompt_resources/scenarios/zh-CN/scenarios.json @@ -1,28 +1,28 @@ { "scenarios": [ { - "scenario_code": "subscribe_incident", + "scenario_code": "subscribe-incident", "scenario_name": "Incident 事件订阅", "description": "用于生成电信网管系统中的 Incident 事件订阅类 A2A-T 任务请求,围绕网络侧故障、告警或 Incident 通知的订阅目标、订阅条件和上报方式进行描述。", "example": "帮我生成一个 Incident 订阅任务:订阅基站设备的光纤中断和尾纤故障,只看 critical 和 high,通过 DataPart 上报。" }, { - "scenario_code": "energy_saving", + "scenario_code": "ran-energy-saving", "scenario_name": "能效优化任务下发", "description": "用于生成电信网管系统中 能效优化任务的 A2A-T 任务请求。", "example": "实现松山湖区域30%的能耗降低,同时确保速率保障不低于50Mbps。" }, { - "scenario_code": "private_line_complaint", + "scenario_code": "private-line-complaint", "scenario_name": "专线投诉", "description": "用于生成电信网管系统中 专线投诉场景的网络侧故障根因诊断的 A2A-T 任务请求。", "example": "请确认广东政企专线2026-05-10T18:25:30Z发生的故障fault-id-1-328-20260510-58489的根因,并提供修复建议。" }, { - "scenario_code": "fault_diagnosis", - "scenario_name": "故障根因诊断", - "description": "根据事件识别输出的事件信息和初步定界分析、初步根因分析、初步影响分析等信息,输出根因定位信息及建议处理方案。", - "example": "请根据如下信息进行故障事件的根因定位,输出修复建议和业务影响分析:事件流水号是1856365516_2839324485_2130908106_4130674041,标题为5G基站退服事件,事件关联的告警厂家内部流水号为123211121244,事件关联告警在网管系统内唯一标识为0108-007-012-10-000003,事件关联告警标题是gNodeB退服告警,事件产生时间为2025-05-22 08:40:44,告警级别是四级,网元名称为泉州惠安-惠安张坂玉山灯杆G-HRHH。" + "scenario_code": "service-recovery", + "scenario_name": "业务抢通事件订阅", + "description": "用于生成电信网管系统中业务抢通事件订阅类的 A2A-T 任务请求,围绕网络侧业务抢通事件的订阅条件与上报通知数据格式进行描述。", + "example": "帮我订阅业务抢通事件:只看xx子网,事件上报时带上抢通方案执行状态、诊断任务流水号、接入端口和执行结果这些信息。" } ] } 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..1c44d737 --- /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,29 @@ +{ + "$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. When the input contains an allowed value itself or its abbreviation (e.g., \"add\", \"modify\", \"delete\", \"query\"), map it directly to the corresponding allowed value. Recognized synonym pairs are limited to: create/new/append→add authorization policy, update/edit/change to/adjust to→modify authorization policy, remove/take away/clear→delete authorization policy, view/retrieve/search/find/look up→query authorization policy. Any other verbs not in this list (e.g., export, import, sync, copy, migrate, suspend, approve, archive, download, backup) are out of range and must not be mapped to any allowed value." + }, + "network_operation_authorization_policy_list": { + "type": "string", + "description": "Provide the network operation authorization policy list. Each entry starts with an Arabic number followed by '.' (increasing consecutively from 1); within an entry, each field is expressed as 'field name is value' and separated by commas; multiple entries are each on their own line (separated by newlines).", + "examples": [ + "1. business scenario is service complaint diagnosis, handling type is service recovery, operation name is tunnel optimization, validity period is 2026-06-01~2030-06-18\n2. business scenario is carrier scheduling, handling type is service recovery, operation name is carrier scheduling, validity period is permanently valid", + "1. policy identifier is 7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3, validity period is 2026-06-01~2030-06-18", + "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; 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": [ + "authorization_policy_operation_type" + ] +} 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..1c89f05d --- /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,29 @@ +{ + "$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": "请提供动网操作的授权策略列表,每条以阿拉伯数字编号加'.'开头(编号从 1 起连续递增),条目内各字段以'字段名是值'形式表达并用全角逗号','分隔,多个条目各自独立成行(换行分隔)。", + "examples": [ + "1. 业务场景是校园专网,处置类型是紧急扩容,操作名称是天线调整,有效期是2026-08-01~2030-12-31\n2. 业务场景是视频保障,处置类型是故障切换,操作名称是频谱重耕,有效期是永久生效", + "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是2026-06-01~2030-06-18", + "1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "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": [ + "授权策略的操作类型" + ] +} 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/slots/Notification-T/network-layer/subscribe-incident/v1/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 new file mode 100644 index 00000000..801ef618 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Notification-T/network-layer/subscribe-incident/v1/en-US/slot.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "notification_topic": { + "type": "string", + "description": "The name of the notification topic to be subscribed, used to define the subscription target.", + "examples": [ + "Incident" + ], + "x-a2at-value-constraint": "The specific topic name can be: Incident, Fault, intelligent fault, fault, etc." + }, + "subscribe_condition": { + "type": "string", + "description": "The condition description to be subscribed, used to define subscription constraints such as fault name and fault severity.", + "examples": [ + "Fault priority: critical, high. Fault name: fiber break, board fault.", + "Fault priority: high, medium. Fault name: board fault, main control board abnormal, port abnormal." + ], + "x-a2at-value-constraint": "The subscription condition may include fault priority and fault name. Fault priority supports a list form. Priority semantics support synonymous Chinese/English expressions; a match on either the Chinese or English term is considered valid: urgent/critical, high/high, medium/medium, low/low. Fault name supports a list form, with allowed values being the list of network-side fault names, for example: pigtail fault, fiber break, board fault, optical module fault, etc." + }, + "notification_data_format": { + "type": "string", + "description": "The notification data format description to be reported, used to define the data type and carrying format.", + "examples": [ + "Report Incident data via DataPart." + ], + "x-a2at-value-constraint": "May describe the reported data type and carrying Part. The data type can be Incident, fault, etc.; the carrying format can be an A2A Part, such as DataPart or TextPart." + } + }, + "required": [] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/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/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/network-layer/private-line-complaint/v1/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 new file mode 100644 index 00000000..f3ccb2e4 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/en-US/slot.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "task_object": { + "type": "string", + "description": "Private line name, access port name, and access port resource ID; provide one of the three parameters to identify the private line service object.", + "examples": [ + "Access port resource ID: f47ac10b-58cc-4372-a567-0e02b2c3d479" + ], + "x-a2at-value-constraint": "Private line name: the private line object identifier in the network-side EMS NMS. Access port name: the access port name in the network-side EMS NMS, in the format: network element name + board number + board model + port number. Access port resource ID: the access port resource object identifier in the network-side EMS NMS." + }, + "task_context": { + "type": "string", + "description": "Provide the fault phenomenon description of the private line service and the context information of the diagnosis task.", + "examples": [ + "Fault phenomenon: private line interruption. Fault sequence number: fault-id-1-017-20230511-09013" + ], + "x-a2at-value-constraint": "Fault phenomenon: includes two scenarios, \"private line interruption\" and \"poor private line quality\". Required parameter. Fault occurrence time. Optional parameter. Fault or event sequence number on the OSS side. Optional parameter." + } + }, + "required": [ + "task_context", "task_object" + ] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json new file mode 100644 index 00000000..e17a3034 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/private-line-complaint/v1/zh-CN/slot.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "任务对象": { + "type": "string", + "description": "支持传入 专线业务名称/专线业务标识/专线业务接入端口名称,选择其中1种方式来表示专线业务对象", + "examples": [ + "专线业务名称:惠州江北汇聚专线业务_100M_SPN" + ], + "x-a2at-value-constraint": "提取格式为:对象类型:对应的值,专线业务名称:网络侧的专线业务名称。专线业务标识:网络侧的专线业务唯一标识。接入端口名称:业务的接入端口名称,可以是物理端口或者逻辑端口(子接口),物理端口格式为:网元名称+单板号+单板型号+端口号,逻辑端口格式为:网元名称+单板号+单板型号+端口号(cvlan=接入vlanid)。" + }, + "任务上下文": { + "type": "string", + "description": "提供专线业务的故障现象描述和诊断任务的上下文信息", + "examples": [ + "投诉分类:专线中断。OSS侧事件流水号:fault-id-1-017-20230511-09013" + ], + "x-a2at-value-constraint": "投诉分类:仅包含“专线中断”和“专线质差”两种场景,必选参数。问题发生时间,可选参数,若传入的参数不为时间戳,需要转换为ISO-8601 时间戳。OSS侧的故障或者事件流水号,必选参数。投诉详情,可选参数。" + } + }, + "required": [ + "任务上下文", "任务对象" + ] +} \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json new file mode 100644 index 00000000..5ec75144 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/en-US/slot.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "operation_type": { + "type": "string", + "description": "Provide the operation type. Allowed values: Create, Modify.", + "examples": [ + "Create" + ], + "x-a2at-value-constraint": "Allowed values: Create, Modify" + }, + "task_description": { + "type": "string", + "description": "Dynamically provide the task description based on the operation type (Create/Modify).", + "examples": [ + "Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in ." + ], + "x-a2at-value-constraint": "When the type is [Create]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in . When the type is [Modify]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in ." + }, + "task_object": { + "type": "string", + "description": "Explicitly specify the energy saving coverage scope information, a geographic or logical area (e.g., district-level administrative unit). The area information is a required parameter.", + "examples": [ + "The area is the Songshanhu Administration Committee." + ], + "x-a2at-value-constraint": "Area name. The area information (important) must be a concrete physical address, for example a real physical community such as \"Songshanhu Administration Committee\", rather than a description without a specific physical location such as \"all communities\", \"indoor communities\", or \"outdoor communities\"." + }, + "task_target": { + "type": "string", + "description": "Define the quantitative or qualitative goals the task must achieve. Quantitative and qualitative goals cannot be selected at the same time, and the default is a qualitative goal.", + "examples": [ + "Energy consumption goal: 30%" + ], + "x-a2at-value-constraint": "1. Quantitative goal - Energy consumption goal: the gain target of energy saving. The target value format must be a percentage, such as the percentage by which total power consumption is reduced. 2. Qualitative goal - Maximize energy saving under the conditions defined in the task context." + }, + "task_context": { + "type": "string", + "description": "Provide the background and context information for task execution.", + "examples": [ + "Cell RAT for energy saving: NR; Energy saving time range: start time \"08:00:00\", end time \"12:00:00\"; Time-segmented rate guarantee goal: {00:00~06:00,2Mbps}, {06:00~18:00,10Mbps}." + ], + "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", + "description": "When the task object is a geographic/logical area, output the intent report for the network energy saving task.", + "examples": [ + "" + ], + "x-a2at-value-constraint": "Basic information about the intent execution result: area, intent goal achievement status. Detailed information about the intent execution result: energy saving gain achievement status (current energy saving gain goal, actual energy saving gain percentage, target adjustment suggestions), rate goal achievement status (current rate goal, actual rate, target adjustment suggestions)." + } + }, + "required": ["operation_type", "task_object", "task_description"] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/zh-CN/slot.json new file mode 100644 index 00000000..700a8fd0 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/slots/Task-T/network-layer/ran-energy-saving/v1/zh-CN/slot.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "操作类型": { + "type": "string", + "description": "请提供操作类型,取值范围:创建、修改。", + "examples": [ + "创建" + ], + "x-a2at-value-constraint": "取值范围:创建、修改" + }, + "任务描述": { + "type": "string", + "description": "根据操作类型(创建/修改),动态提供任务描述信息。", + "examples": [ + "基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。" + ], + "x-a2at-value-constraint": "类型为【创建】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。类型为【修改】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标。" + }, + "任务对象": { + "type": "string", + "description": "明确指定节能覆盖范围信息,地理或逻辑区域(如 区级行政单位)。区域信息为必选参数。", + "examples": [ + "区域为松山湖管委会。" + ], + "x-a2at-value-constraint": "区域名称。区域信息(重要),是具体物理地址,例如“松山湖管委会”这种物理真实小区,而不是“所有小区”、“室内小区”、“室外小区”这种无具体物理位置的描述信息。" + }, + "任务目标": { + "type": "string", + "description": "定义任务需达成的量化或定性目标,量化目标和定性目标不可同时选择,默认为定性目标。", + "examples": [ + "能耗目标:30%" + ], + "x-a2at-value-constraint": "1. 量化目标 能耗目标:节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比。 2. 定性目标 在满足任务上下文的条件下进行最大化节能。" + }, + "任务上下文": { + "type": "string", + "description": "提供任务执行的背景和上下文信息。", + "examples": [ + "节能的小区制式:NR;需要节能的时间范围:起始时间“08:00:00”,结束时间“12:00:00”;分时段速率保障目标:{00:00~06:00,2Mbps}、{06:00~18:00,10Mbps}。" + ], + "x-a2at-value-constraint": "1. 节能的小区制式(LTE/NR/全部) 2. 需要执行节能的时间段区间范围,以天为周期循环执行,缺省则进行全时段节能,仅在节能开启时间范围内保障速率目标 3. 可在节能时关闭载波的频点 4. 速率保障目标:需要保障的下行吞吐率目标最小值及生效时间段(区域级支持分时段/全时段,未设置速率保障目标的时间段默认采取用户体验无损方式节能,最多设置三种速率值;站点级需提供站点列表、分时段速率保障目标、可选背景事件与持续时间)" + }, + "预期输出": { + "type": "string", + "description": "若任务对象为地理/逻辑区域时,输出网络节能任务的意图报告。", + "examples": [ + "" + ], + "x-a2at-value-constraint": "意图的执行结果基本信息:区域、意图目标达成情况。意图的执行结果详情:节能增益达成情况(当前节能增益目标、实际节能增益百分比、目标调整建议)、速率目标达成情况(当前速率目标、实际速率、目标调整建议)。" + } + }, + "required": ["操作类型", "任务对象", "任务描述"] +} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/zh-CN/slot.json deleted file mode 100644 index 2cd122a0..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/energy_saving/zh-CN/slot.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "additionalProperties": false, - "properties": { - "任务目标": { - "type": "string", - "description": "定义任务需达成的量化或定性目标,如能效目标和速率保障目标。", - "examples": [ - "目标是实现30%的能耗降低,同时确保速率保障不低于50Mbps。" - ], - "x-a2at-value-constraint": "能耗目标:节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比。速率保障目标:需要保障的下行吞吐率目标最小值。" - }, - "任务对象": { - "type": "string", - "description": "明确指定节能区域信息,地理或逻辑区域(如:区级行政单位)或经纬度坐标。", - "examples": [ - "区域为松山湖管委会。" - ], - "x-a2at-value-constraint": "地理/逻辑的区域名称。区域信息(重要),是具体物理地址," - }, - "任务上下文": { - "type": "string", - "description": "提供任务执行的背景和上下文信息。", - "examples": [ - "" - ], - "x-a2at-value-constraint": "" - }, - "约束条件": { - "type": "string", - "description": "列出任务执行中必须遵守的规则、限制和边界条件,约束条件默认对区域内所有场景均适用,针对区域内指定场景适用的约束条件,请单独说明。", - "examples": [ - "采用NR制式的小区节能方案,并将1650频点设为执行载波关断的基础层。在节能策略上,系统将采取激进节能模式,但前提是必须确保体验无损,即在不影响用户感知的情况下进行。该节能任务以天为周期循环执行,具体的生效时间段为每日08:00:00至12:00:00。此外,方案中还包含两项特定场景的约束:在商业区场景下,禁止将1300频点作为容量层使用;而在黑森林场景下,则禁止在凌晨0点至6点期间开启修秒功能。" - ], - "x-a2at-value-constraint": "1. 需要节能的小区类型(室内/室外/所有)2. 节能的小区制式(LTE/NR/全部)3. 对基础层和容量层的频点要求,频点的表述方式必须为下行频点号 4. 节能的激进程度是激进节能还是保守节能 5. 是否要求体验无损节能 6. 需要执行节能的时间段区间范围,以天为周期循环执行 7. 针对区域内制定场景适用的约束条件(如:铁路/黑森林/商务区/居民区) 8. 是否允许在节能时关闭载波的频点" - } - }, - "required": [] -} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/zh-CN/slot.json deleted file mode 100644 index 5287ca60..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/fault_diagnosis/zh-CN/slot.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "additionalProperties": false, - "properties": { - "任务目标": { - "type": "string", - "description": "故障根因诊断的具体目标", - "examples": [ - "请进行故障事件的根因定位,输出专业根因定位信息、故障根因专业分类、故障根因详情、远程处置方案、现场抢修方案及业务影响分析。" - ], - "x-a2at-value-constraint": "说明对故障诊断任务的具体要求,如完成故障事件的根因定位,输出专业根因定位信息、故障根因专业分类、故障根因详情、远程处置方案、现场抢修方案及业务影响分析。" - }, - "任务上下文": { - "type": "string", - "description": "故障根因诊断任务的请求参数", - "examples": [ - "有1条待诊断的告警事件,具体内容如下:\r\n\r\n- 事件流水号是1856365516_2839324485_2130908106_4130674041,标题为5G基站退服事件,事件关联的告警厂家内部流水号为123211121244,事件关联告警在网管系统内唯一标识为0108-007-012-10-000003,事件关联告警标题是gNodeB退服告警,事件产生时间为2025-05-22 08:40:44,告警级别是四级,网元名称为泉州惠安-惠安张坂玉山灯杆G-HRHH。" - ], - "x-a2at-value-constraint": "故障根因诊断任务可包含多条告警事件,每条事件可包含以下信息:\r\n1. 故障事件流水号,必选。样例:1856365516_2839324485_2130908106_4130674041\r\n2. 事件标题名称,必选。样例:5G基站退服事件\r\n3. 事件关联的告警厂家内部流水号,必选。样例:123211121244\r\n4. 事件关联的传输专业告警扩展流水号,可选。\r\n5. 事件关联告警在网管系统内唯一标识,必选。样例:0108-007-012-10-000003\r\n6. 事件关联告警标题,必选。样例:gNodeB退服告警\r\n7. 事件产生时间,必选。样例:2025-05-22 08:40:44\r\n8. 告警级别,数字越小越严重。一级告警(Critical);二级告警(Major);三级告警(Minor);四级告警(Warning)。必选。样例:四级告警\r\n9. 基站ID,无线工作台用。可选。样例:2148660\r\n10. 网元名称,必选。样例:泉州惠安-惠安张坂玉山灯杆G-HRHH\r\n11. 电路号,可选。样例:泉州惠安张坂惠南联兴-泉州惠安局楼10GE1097086NR\r\n12. 机房信息,可选。样例:泉州惠安张坂玉山一楼自建机房" - }, - "预期输出": { - "type": "string", - "description": "对返回的故障诊断结果的具体要求", - "examples": [ - "针对预期返回的故障诊断结果,需要声明如下信息:\r\n\r\n1. 需要声明基于A2A的哪一类Part承载,如DataPart或TextPart。\r\n2. 需要声明包含哪些信息要素(如诊断结果、定位结论、定位分析过程等) " - ], - "x-a2at-value-constraint": "故障根因诊断任务可包含多条告警事件,每条事件可包含以下信息:\r\n1. 故障事件流水号,必选。样例:1856365516_2839324485_2130908106_4130674041\r\n2. 事件标题名称,必选。样例:5G基站退服事件\r\n3. 事件关联的告警厂家内部流水号,必选。样例:123211121244\r\n4. 事件关联的传输专业告警扩展流水号,可选。\r\n5. 事件关联告警在网管系统内唯一标识,必选。样例:0108-007-012-10-000003\r\n6. 事件关联告警标题,必选。样例:gNodeB退服告警\r\n7. 事件产生时间,必选。样例:2025-05-22 08:40:44\r\n8. 告警级别,数字越小越严重。一级告警(Critical);二级告警(Major);三级告警(Minor);四级告警(Warning)。必选。样例:四级告警\r\n9. 基站ID,无线工作台用。可选。样例:2148660\r\n10. 网元名称,必选。样例:泉州惠安-惠安张坂玉山灯杆G-HRHH\r\n11. 电路号,可选。样例:泉州惠安张坂惠南联兴-泉州惠安局楼10GE1097086NR\r\n12. 机房信息,可选。样例:泉州惠安张坂玉山一楼自建机房" - } - }, - "required": [ - "任务目标", - "任务上下文" - ] -} \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/slots/private_line_complaint/zh-CN/slot.json b/a2a-t-resources/src/main/resources/prompt_resources/slots/private_line_complaint/zh-CN/slot.json deleted file mode 100644 index 576d961d..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/slots/private_line_complaint/zh-CN/slot.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "additionalProperties": false, - "properties": { - "任务对象": { - "type": "string", - "description": "专线名称,接入端口名称,接入端口资源ID参数,3个参数下发其中一个用于标识专线业务对象", - "examples": [ - "接入端口资源ID:f47ac10b-58cc-4372-a567-0e02b2c3d479" - ], - "x-a2at-value-constraint": "专线名称:网络侧EMS网管的专线对象标识。接入端口名称:网络侧EMS网管的接入端口名称,格式为:网元名称+单板号+单板型号+端口号。接入端口资源ID:网络侧EMS网管的接入段口资源对象标识。" - }, - "任务上下文": { - "type": "string", - "description": "提供专线业务的故障现象描述和诊断任务的上下文信息", - "examples": [ - "故障现象:专线中断。故障流水号:fault-id-1-017-20230511-09013" - ], - "x-a2at-value-constraint": "故障现象:包含“专线中断”和“专线质量差”两种场景,必选参数。故障发生时间,可选参数。OSS侧的故障或者事件流水号,可选参数。" - }, - "预期输出": { - "type": "string", - "description": "专线投诉任务预期输出", - "examples": [ - "返回诊断成功的专线投诉诊断任务的详细信息及修复建议。" - ], - "x-a2at-value-constraint": "包含诊断结果类型,诊断结果详细信息,修复建议。诊断结果类型参数的取值范围包括:诊断成功、诊断失败、诊断未开始或者诊断未结束。可以包括多个故障根因,每个故障根因包含:故障根因的名称、详细描述、修复建议,故障根因点所在资源对象的标识、类型、名称、详细位置等信息。故障整体的详细描述,故障根因的修复建议。如果存在多故障根因,需要对多个故障根因中的详细描述和修复建议总结提炼到总体的诊断结果详情和修复建议中。" - } - }, - "required": [ - "任务上下文" - ] -} diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/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 new file mode 100644 index 00000000..918bc847 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/en-US/template.md @@ -0,0 +1,37 @@ +## Authorization Policy Operation Type +{{authorization_policy_operation_type}} (required) +Requirement: +Provide the operation type of the authorization policy. Allowed values: add authorization policy, modify authorization policy, delete authorization policy, query authorization policy + +## Authorization Policy Operation Description +Please complete the corresponding authorization operation based on and , and return the authorization policy operation execution result in the structure defined in . indicates the expected return content. + +## Network Operation Authorization Policy List +{{network_operation_authorization_policy_list}} (required for add, required for modify, required for delete, optional for query) +Requirement: +1. Supports a list format, where each item in the list contains the following information: + - Network operation authorization policy identifier: uniquely identifies a network operation authorization policy. Example: 7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3 + - Network operation business scenario: the business scenario supported by the network operation. Example: service complaint diagnosis + - Network operation handling type. Example: service recovery + - Network operation name: the specific name of the authorized network operation. Example: tunnel optimization + - Validity period: the validity time range of the authorization policy. If the value is "permanently valid", it means the authorization is permanently valid with no validity period limit. Example: 2026-06-01T12:00:00Z ~ 2030-06-18T12:00:00Z + - Creation time: the time when the authorization policy was created. Example: 2026-06-18T12:00:00Z + - Last modification time: the time when the authorization policy was last modified. Example: 2026-06-18T12:00:00Z + +2. When adding an authorization policy, the required parameters include: network operation business scenario, network operation handling type, network operation name, validity period + When modifying an authorization policy, the parameter that can be modified is: validity period + When querying authorization policies, the supported query conditions are: network operation business scenario, network operation handling type, network operation name + 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 +2. When the authorization operation is executed successfully, return the that was executed successfully +3. When the authorization operation fails or is partially successful, return a failure list containing the authorization policies and the failure reasons + +## Terminology Explanation +1. Service complaint diagnosis + - Synonyms: Leased Line Service Complaint, corporate leased line complaint, corporate business complaint, leased line complaint, corporate leased line business complaint diagnosis, leased line business complaint diagnosis, leased line complaint diagnosis, circuit complaint, leased line business appeal, leased line service complaint, group leased line business complaint, government-enterprise leased line customer complaint, leased line complaint event +2. Service recovery + - Synonyms: Service Restoration, Service Recovery, restore service, service recovery, temporary service recovery, rapid service recovery, rapid service restoration, emergency service restoration, service restoration handling +3. Tunnel optimization + - Synonyms: Tunnel Optimization, tunnel optimization, tunnel path optimization, tunnel service optimization, tunnel resource optimization, tunnel rerouting optimization, tunnel routing regularization, tunnel path reorganization diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md new file mode 100644 index 00000000..79e22db3 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Authorization-T/authorization-policy-management/v1/zh-CN/template.md @@ -0,0 +1,37 @@ +## 授权策略的操作类型 +{{授权策略的操作类型}} (必填) +要求: +提供授权策略的操作类型,取值范围:新增授权策略、修改授权策略、删除授权策略、查询授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +{{动网操作的授权策略列表}} (新增必填,修改必填,删除必填,查询选填) +要求: +1. 支持列表形式,列表中的每一项包含如下信息: + - 动网操作的授权策略标识:唯一标识一条动网操作授权策略; 举例:7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3; + - 动网操作的业务场景:动网操作支撑的业务场景;举例:业务投诉诊断; + - 动网操作的处置类型;举例:业务抢通; + - 动网操作名称:所授权的动网操作的具体名称;举例:隧道调优; + - 有效期:授权策略的生效时间区间;如果取值为“永久生效”,则表示该授权永久生效,不限定有效期;举例:2026-06-01T12:00:00Z ~ 2030-06-18T12:00:00Z; + - 创建时间:创建授权策略的时间;举例:2026-06-18T12:00:00Z; + - 最后修改时间:最后一次修改授权策略的时间;举例:2026-06-18T12:00:00Z; + +2. 创建授权策略时,必填参数包括:动网操作的业务场景、动网操作的处置类型、动网操作名称、有效期 + 修改授权策略时,支持修改的参数为:有效期 + 查询授权策略时支持的查询条件为:动网操作的业务场景、动网操作的处置类型、动网操作名称 + 删除授权策略时,必填参数包括:动网操作的授权策略标识 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; + +## 术语解释(Terminology Explanation) +1. 业务投诉诊断 + - 同义词术语:Leased Line Service Complaint,集客专线投诉,集客业务投诉,专线投诉,集客专线业务投诉诊断,专线业务投诉诊断,专线投诉诊断,电路投诉,专线业务申诉,专线服务投诉,集团专线业务投诉,政企专线客户投诉,专线投诉事件 +2. 业务抢通 + - 同义词术语:Service Restoration,Service Recovery, 恢复业务,业务恢复,业务临时恢复,业务快速恢复,业务快速抢通,业务应急抢通,业务抢通处置 +3. 隧道调优 + - 同义词术语:Tunnel Optimization,隧道优化,隧道路径调优,隧道业务调优,隧道资源调优,隧道路径优化,隧道重路由优化,隧道路由规整,隧道路径重整 \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/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..113a7489 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/en-US/template.md @@ -0,0 +1,9 @@ +## 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..4c177577 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/common/abort/v1/zh-CN/template.md @@ -0,0 +1,9 @@ +## 协商结果 +Abort + +## 协商终止原因 +{{协商终止原因}}(必填) +要求: +说明协商终止的原因,如达到协商轮次上限、超时、token消耗超限等 +举例: +达到协商轮次上限,本次协商确认结束。 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 new file mode 100644 index 00000000..3d91f156 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/en-US/template.md @@ -0,0 +1,23 @@ +## Feasibility Negotiation Result +{{feasibility_negotiation_result}} (required) +Requirement: +1. If the assessment result is feasible, or the assessment result provided by the counterparty is accepted, return Accept +2. If the assessment result is infeasible, or the assessment result provided by the counterparty is not accepted, return Reject +3. The conclusion must be one of the two + +## Feasibility Assessment Result Confirmation +{{evaluation_result_confirmation}} (required) +Requirement: +1. If the assessment result is feasible and there are multiple optional solutions in the assessment request, explicitly specify which solution to use +2. If the assessment result is infeasible, state the reason for rejection +3. If the 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 (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 new file mode 100644 index 00000000..7c4a1ba8 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/accept-reject/v1/zh-CN/template.md @@ -0,0 +1,23 @@ +## 可行性协商结果 +{{可行性协商结果}}(必填) +要求: +1. 如果评估结果为可行,或接受对端提供的评估结果,则返回Accept +2. 如果评估结果为不可行,或不接受对端提供的评估结果,则返回Reject +3. 结论必须二选一 + +## 可行性评估结果确认 +{{评估结果确认}}(必填) +要求: +1. 如果评估结果为可行,且评估请求里存在多个可选方案,则明确指定使用哪一个方案 +2. 如果评估结果为不可行,则说明拒绝理由 +3. 如果接收到对方评估可行时的确认请求,且确定按此方案或者目标继续执行,则回复同意 +4. 如果接受对端提供的评估结果,则简要概述对端的评估结果,并说明接受该评估结果 +5. 如果不接受对端提供的评估结果,则简要概述对端的评估结果,并说明不接受该评估结果 + +样例1(Accept):节能目标可达成,本次协商确认结束。 +样例2(Accept):同意将速率保障目标由5Mbps下调至2Mbps,本次协商确认结束。 +样例3(Accept):当前邻区保活方案可行,影响可控,本次协商确认结束。 +样例4(Accept):接受节能目标达成不可行的评估结果,本次协商确认结束。 +样例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 new file mode 100644 index 00000000..f05e9f18 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/en-US/template.md @@ -0,0 +1,62 @@ +## Feasibility Negotiation +{{feasibility_negotiation_summary}} (required) +Requirement: +1. State whether the feasibility assessment category is goal achievement or solution feasibility +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 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 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 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 +5. This side does not provide a conclusion, only the complete information of the object to be evaluated, to avoid omissions that would leave the counterparty with insufficient basis for assessment +6. Related items must be described one by one, not only listing some of them + +Example: The target to be evaluated is the rate guarantee goal of the site-level energy saving task during the 08:00~18:00 period, which has been adjusted from the original 5Mbps down to 2Mbps. The basis for the adjustment is to accommodate the power outage duration guarantee requirement during the 08:00~18:00 period. Please assess whether this adjusted rate goal can be achieved, and whether it can simultaneously satisfy the original power outage duration guarantee requirement. + +## Infeasible Evaluation Details and Proposal +{{infeasible_evaluation_details_and_proposal}} (optional) +Requirement: +1. Provide this section only when the 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 +5. Give a clear conclusion of infeasible +6. State the basis +7. Provide adjustment suggestions or feasible candidate solutions +8. Related items must be described one by one, not only listing some of them + +Example: +This assessment targets the goal achievement feasibility of the site-level energy saving task. +The original request requires the xx site to deliver an energy saving guaranteed rate of 5Mbps during the 08:00~18:00 period, while the site has an existing constraint of guaranteeing at least 10 hours of power supply duration under power outage scenarios. +Upon assessment, if the 5Mbps rate guarantee goal is executed, the equipment power consumption during this period cannot satisfy the power outage duration requirement. The two requirements conflict, and the basis for this judgment is the current backup power capacity of the site and the historical power consumption curve. +Conclusion: Infeasible. +Proposal: Regarding the conflict between the rate guarantee goal and the power outage duration requirement, it is recommended to adjust the rate guarantee goal from 5Mbps down to 2Mbps, so as to extend the supportable power outage duration to the required 10 hours. If the rate reduction cannot be accepted, the power outage duration guarantee requirement must be shortened accordingly. +One of the two must be adjusted; there is currently no third option that can simultaneously satisfy the original two requirements. + +## 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 new file mode 100644 index 00000000..a8d8e11c --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/feasibility-negotiation/propose/v1/zh-CN/template.md @@ -0,0 +1,63 @@ +## 可行性协商 +{{可行性协商概述}}(必填) +要求: +1. 说明可行性评估类别为目标达成还是方案可行性 +2. 说明本轮消息类别,取以下之一: + - **发起可行性评估**:提出待评估目标或方案,本方尚未给出结论(无论是首次提出,还是因上一轮被评估不可行、已调整目标或方案后重新发起,均归为此类) + - **评估可行并请求确认**:本方已完成评估,结论为可行,需对方确认是否按此继续执行 + - **评估不可行并提案**:本方已完成评估,结论为不可行且需对方确认处理策略 +3. 若为"发起可行性评估",需引用<待评估内容说明>,并说明期望对方采取的动作:"请评估";若本次是在上一轮被评估不可行后调整发起,需说明本次调整相对上一轮的变化点,不需要重复陈述未变化的部分 +4. 若为"评估可行并请求确认",需引用<评估可行时的确认请求>,简要说明评估结论为可行,不展开评估过程细节 +5. 若为"评估不可行并提案",需引用<评估不可行时的详情和提案> +6. 保持简洁,一到两句话,不展开复述具体内容 + +样例1(首次发起评估):已收到节能任务请求,请协助评估该节能目标能否达成,详见<待评估内容说明>,请评估 +样例2(提出方案发起评估):故障订阅任务履行过程中,自诊断识别到一个可行的保活方案,详见<待评估内容说明>,请评估该方案是否可采纳 +样例3(调整后再次发起评估):针对此前速率保障目标与停电时长要求的冲突,已将速率保障目标由5Mbps下调至2Mbps,详见<待评估内容说明>,请重新评估该目标是否可达成 +样例4(评估可行并请求确认):针对调整后的速率保障目标,可行性评估已完成,结论为可行,请答复<评估可行时的确认请求> +样例5(评估不可行并提案):站点级节能任务的目标达成可行性评估已完成,当前速率目标无法同时满足停电时长要求,详见<评估不可行时的详情和提案>,请确认 + + +## 待评估内容说明 +{{待评估内容说明}}(选填) +要求: +1. 仅当本轮消息类别为"发起可行性评估"时提供本板块 +2. 说明待评估事项属于目标达成还是方案可行性 +3. 目标达成需描述:目标内容、目标所依赖的方案或资源现状;若本次是在上一轮被评估不可行后调整发起,需额外说明本次的调整值及调整依据 +4. 方案可行性需描述:待评估的候选方案内容(可一个或多个)、方案所需资源及是否与既有约束存在潜在关联;若本次是在上一轮被评估不可行后调整发起,需额外说明本次替换/调整后的方案内容 +5. 本方不提供结论,仅提供待评估对象的完整信息,避免遗漏导致对方评估依据不足 +6. 相关事项需逐项说明,不能只列部分 + +样例:待评估目标为站点级节能任务在08:00~18:00时段的速率保障目标,已由原5Mbps下调至2Mbps,调整依据为兼顾08:00~18:00时段的停电时长保障要求。请评估该调整后的速率目标能否达成,且是否可同时满足原有的停电时长保障要求。 + +## 评估不可行时的详情和提案 +{{评估不可行时的详情和提案}}(选填) +要求: +1. 仅当本轮消息类别为"评估不可行并提案"时,提供本板块 +2. 说明评估属于目标达成还是方案可行性 +3. 目标达成需描述:目标内容、是否存在自相矛盾或多目标冲突、所依赖的方案或资源是否具备,并说明判断依据 +4. 方案可行性需描述:候选方案内容(可一个或多个)、各方案所需资源是否具备、是否与既有约束冲突 +5. 给出明确结论为不可行 +6. 需说明依据 +7. 给出调整建议或可行的候选方案 +8. 相关事项需逐项说明,不能只列部分 + +样例: +本次评估针对站点级节能任务的目标达成可行性。 +原始请求要求xx站点在08:00~18:00时段下发节能保障速率为5Mbps的目标,同时该站点存在停电场景下需保障至少10小时供电时长的既有约束。 +经评估,若按5Mbps速率保障目标执行,该时段内设备功耗无法满足停电时长要求,两项要求之间存在冲突,判断依据为当前站点的备电容量及历史功耗曲线。 +结论:不可行。 +提案:针对速率保障目标与停电时长要求的冲突,建议将速率保障目标由5Mbps下调至2Mbps,以延长可支撑的停电时长至满足要求的10小时。 若无法接受下调速率,则需相应缩短停电时长保障要求。 +两者需二选一调整,暂无第三种可同时满足原有两项要求的方案。 + +## 评估可行时的确认请求 +{{评估可行时的确认请求}}(选填) +要求: +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/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 new file mode 100644 index 00000000..31aba02d --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/en-US/template.md @@ -0,0 +1,19 @@ +## Information Negotiation Result +{{information_negotiation_result}} (required) +Requirement: +1. If the required information can be provided, return Accept +2. If the required information cannot be fully provided, return Reject +3. The conclusion must be one of the two + +## Information Negotiation Result Content +{{information_negotiation_result_content}} (required) +Requirement: +1. If the information can be provided: + - State the name and content +2. If the information cannot be provided: + - State the name and the specific reason why it cannot be provided (optional) +Example 1: +Energy saving area information: Songshanhu +Energy saving rate guarantee goal: 20Mbps +Example 2: +Unable to provide area information diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/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 new file mode 100644 index 00000000..380c86a6 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/accept-reject/v1/zh-CN/template.md @@ -0,0 +1,19 @@ +## 信息协商结果 +{{信息协商结果}}(必填) +要求: +1. 如果能够提供所需信息,则返回Accept +2. 如果无法完整提供所需信息,则返回Reject +3. 结论必须二选一 + +## 信息协商结果内容 +{{信息协商结果内容}}(必填) +要求: +1. 如果能够提供信息: + - 说明名称、内容 +2. 如果无法提供信息: + - 说明名称、无法提供的具体原因(可选) +样例1: +节能区域信息为松山湖 +节能速率保障目标为20Mbps +样例2: +无法提供区域信息 \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/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 new file mode 100644 index 00000000..2276390e --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/en-US/template.md @@ -0,0 +1,11 @@ +## Information Negotiation +Please supplement the relevant content based on . + +## Required Information Items +{{required_information_items}} (required) +Requirement: +Provide the name (required), meaning (optional), format requirement (optional), example (optional), and the relationship between missing items (AND/OR) (optional) for each missing item. +Example: +1. Energy saving area information, e.g., Songshanhu +2. Energy saving rate guarantee goal, e.g., 20Mbps +3. VLANId, e.g., xxxx(cvlan=11) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/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 new file mode 100644 index 00000000..85c4787a --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/information-negotiation/propose/v1/zh-CN/template.md @@ -0,0 +1,11 @@ +## 信息协商 +请根据<所需信息项>补充相关内容。 + +## 所需信息项 +{{所需信息项}}(必填) +要求: +提供每个缺失项的名称(必选)、含义(可选)、格式要求(可选)、样例(可选)、缺失项之间的关系(与、或)(可选)。 +样例: +1. 节能区域信息,如松山湖 +2. 节能速率保障目标,如20Mbps +3. VLANId,如xxxx(cvlan=11) \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/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 new file mode 100644 index 00000000..501c7585 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/en-US/template.md @@ -0,0 +1,21 @@ +## Target Negotiation Result +{{target_negotiation_result}} (required) +Requirement: +1. If all questions can be clarified, return Accept +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 +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 new file mode 100644 index 00000000..878db3be --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/accept-reject/v1/zh-CN/template.md @@ -0,0 +1,21 @@ +## 目标协商结果 +{{目标协商结果}}(必填) +要求: +1. 如果能够澄清所有疑问,则返回Accept +2. 如果接收到对方目标澄清后的确认请求,且确定按此目标继续执行,则返回Accept +3. 如果无法完整澄清所有疑问,则返回Reject +4. 结论必须二选一,不允许"部分同意"等模糊状态 + +## 目标协商结果内容 +{{目标协商结果内容}}(必填) +要求: +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 new file mode 100644 index 00000000..c07bf94c --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/en-US/template.md @@ -0,0 +1,69 @@ +## Target Negotiation +{{target_negotiation_summary}} (required) +Requirement: +Please generate the target negotiation summary according to the following requirements: +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 (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. 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) +Requirement: +1. The coverage must be complete: for each understanding listed in and each question listed in , a response must be given one by one, without omission, and without skipping and then uniformly stating "the rest are agreed" +2. Response to understanding statements: + - The confirmation result must be clearly marked as confirmed (agreed) or corrected (needs correction) + - If corrected, the corrected correct value must be given, and the reason for the correction must be stated. It is not allowed to only mark "incorrect" without giving the correct content +3. The response to question points must correspond to the candidate form given when the question was asked: + - If the questioner gave closed candidates, the response must directly select the candidate number, and must not deviate from the candidates to give a free description + - If the candidates include a "none of the above/other" option and it is selected, specific explanation must be supplemented. It is not allowed to only select that option without giving content + - If the questioner marked it as an open-ended question, the response should be given in a structured "field path + value" form, avoiding a whole paragraph of free text + - If it is truly impossible to answer, mark it as unable-to-answer and state the reason (such as insufficient permissions, information temporarily unavailable). It is not allowed to leave it blank or replace a clear "cannot answer" with vague language +4. The response scope must strictly align with the field scope of the statements/questions: + - Must not introduce new field confirmations or new questions outside the statements and questions in this section +5. The response content must maintain the same atomic granularity as the original question points: one question point corresponds to only one response. The responses to multiple question points must not be merged into one comprehensive statement + +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: +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 new file mode 100644 index 00000000..179b4a9f --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Negotiation-T/target-negotiation/propose/v1/zh-CN/template.md @@ -0,0 +1,70 @@ +## 目标协商 +{{目标协商概述}}(必填) +要求: +请按照如下要求生成目标协商概述: +1. 说明本轮消息类别,取以下之一: + - **理解陈述/疑问澄清**: + - 若为首轮协商,需引用<意图理解陈述>陈述对原始意图的理解 + - 若为非首轮协商(本轮消息是对上一轮疑问的应答,包含<理解对齐与疑问澄清>),引用<理解对齐与疑问澄清> + - 若本轮存在待澄清的疑问点,需引用<待澄清内容>并提示需要澄清 + - 需明确提出本轮期望对方采取的动作,如"请澄清和确认",动作表述需与本轮实际板块内容对应(如本轮仅需确认理解、无新疑问时,动作应为"请确认") + - **目标已澄清并请求对方确认**:任务目标澄清完成,需对方确认是否按此目标继续执行 + - 内容固定为:任务目标澄清完成,请答复<目标澄清后的确认请求> +2. 需概括本轮待澄清疑问的核心主题(仅"理解陈述/疑问澄清"场景需要),概述需保持简洁,控制在一到两句话内,不得展开复述<意图理解陈述>/<理解对齐与疑问澄清>/<待澄清内容>/<目标澄清后的确认请求>中的具体内容 + +样例1(首轮,有疑问):对无线网络节能任务的意图理解参见<意图理解陈述>,对区域和节能的时间范围存在疑问,详见<待澄清内容>,请澄清和确认 +样例2(非首轮,仍有疑问):已针对无线网络节能任务中的节能时间范围提供疑问澄清,详见<理解对齐与疑问澄清>,对区域信息仍有疑问,详见<待澄清内容>,请澄清和确认 +样例3(目标已澄清,请求确认):任务目标澄清完成,请答复<目标澄清后的确认请求> + +## 意图理解陈述 +{{意图理解陈述}}(选填) +要求: +请按照如下要求提供对原始意图的理解: +1. 必须是对原始请求的复述性理解,不得引入原始请求中不存在的新假设 +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} + +## 理解对齐与疑问澄清 +{{理解对齐与疑问澄清}}(选填) +要求: +1. 覆盖度要完整:对<意图理解陈述>中列出的每条理解、<待澄清内容>中列出的每个疑问点,均需逐条给出应答,不得遗漏,也不得跳过后统一说明"其余同意" +2. 理解陈述的应答: + - 确认结果需明确标注为 confirmed(认同)或 corrected(需修正) + - 若为 corrected,必须给出修正后的正确值,并说明修正理由,不能只标"不对"而不给出正确内容 +3. 疑问点的应答需与提问时给出的候选形式对应: + - 若提问方给出了封闭候选,应答须直接选定候选编号,不得脱离候选另行自由描述 + - 若候选包含"以上都不是/其他"选项且被选中,必须补充具体说明,不能仅选该项而不给内容 + - 若提问方标注为开放式问题,应答以结构化的"字段路径+值"形式给出,避免整段自由文本 + - 若确实无法回答,需标注为 unable-to-answer 并说明原因(如权限不足、信息暂不可得),不得留空或用模糊语言代替明确的"无法回答" +4. 应答范围需与陈述/疑问的字段范围严格对齐: + - 不得在本板块中引入陈述和疑问之外的新字段确认或新疑问 +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. 内容固定为:"目标已经澄清,是否同意按照此目标继续执行?" 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 new file mode 100644 index 00000000..fd3d11c7 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/en-US/template.md @@ -0,0 +1,46 @@ +## Subscription Description +Please complete the network-side service recovery event subscription and reporting task based on the following , , , and information. + +## Notification Topic +Service recovery event + +## Subscribe Condition +{{subscribe_condition}} (optional) +1. Subnetwork name. Example: xx subnetwork + +## Notification Data Format +{{notification_data_format}} (required) +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 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) +4. Access port name. Example: P781-SZ-PTN7900-23-TPA1EG24-17 (required) +5. Whether OMC automatic recovery is authorized. Allowed values: yes, no. Example: yes (required) +6. Service recovery plan name. Example: tunnel optimization (required) +7. Service recovery plan details (required) + Example: + Tune the list of tunnels affected by traffic bandwidth, latency violation, and packet loss violation to a new path list. + Before optimization: + - Tunnel 1: Tunnel a, source NE: P781-SZ-PTN7900, sink NE: P783-SZ-PTN7900, primary/secondary type: master, status: down, latency: --, packet loss rate: 100%, bandwidth: 10000bps + - Tunnel route list: + - Link 1: Link a, source NE: P781-SZ-PTN7900, source port: 23-TPA1EG24-17, sink NE: P782-SZ-PTN7900, sink port: 23-TPA1EG24-17, latency: -- + - Link 2: Link b, source NE: P782-SZ-PTN7900, source port: 23-TPA1EG24-17, sink NE: P783-SZ-PTN7900, sink port: 23-TPA1EG24-17, latency: -- + After optimization: + - Tunnel 1: Tunnel b, source NE: P881-SZ-PTN7900, sink NE: P883-SZ-PTN7900, primary/secondary type: master, status: up, latency: 20ms, packet loss rate: 0.1%, bandwidth: 10000bps + - Tunnel route list: + - Link 1: Link c, source NE: P881-SZ-PTN7900, source port: 25-TPA1EG24-11, sink NE: P882-SZ-PTN7900, sink port: 25-TPA1EG24-12, latency: 10ms + - Link 2: Link d, source NE: P882-SZ-PTN7900, source port: 25-TPA1EG24-13, sink NE: P883-SZ-PTN7900, sink port: 25-TPA1EG24-14, latency: 15ms +8. Service recovery plan execution end time. Example: 2026-05-11T08:21:46Z (optional) +9. Service recovery plan execution result. Allowed values: success, failure. Example: success (optional) +10. Service recovery plan execution failure reason. This information must be provided when the service recovery plan execution result is "failure". Example: After optimization, tunnel link C is occupied and tunnel optimization cannot be performed (optional) + +## Expected Output +1. Subscription result. Allowed values: success +2. After successful subscription, report messages according to diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md new file mode 100644 index 00000000..6f68c97c --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/service-recovery/v1/zh-CN/template.md @@ -0,0 +1,46 @@ +## 订阅描述 +请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。 + +## 通知主题 +业务抢通事件 + +## 订阅条件 +{{订阅条件}}(可选) +1. 子网名称;举例:xx子网; + +## 上报通知数据格式 +{{上报通知数据格式}}(必选) +要求:请提供所需上报内容的如下信息 +1. 信息项名称;(必选) +2. 取值范围;(可选) +3. 样例;(可选) + +如下为上报通知数据格式样例: +### 业务抢通事件 +要求: +1. 业务抢通方案执行状态,取值范围:未启动、已结束;举例:已结束 (必选) +2. 投诉诊断任务流水号;举例:9de168c0-6179-4778-8b72-4279582c0a3f (必选) +3. OSS侧事件流水号;举例:event-id-202606250128 (必选) +4. 接入端口名称;举例:P781-SZ-PTN7900-23-TPA1EG24-17 (必选) +5. 是否已授权OMC自动抢通,取值范围:是、否;举例:是 (必选) +6. 业务抢通方案名称;举例:隧道调优 (必选) +7. 业务抢通方案详情;(必选) + 举例: + 将流量带宽,时延越限和丢包越限受影响的隧道列表,调优到新的路径列表中。 + 调优前: + - 隧道1:隧道a,源网元:P781-SZ-PTN7900,宿网元:P783-SZ-PTN7900,主备类型:master,状态:down,时延:--,丢包率:100%,带宽:10000bps + - 隧道路由列表: + - 链路1:链路a, 源网元:P781-SZ-PTN7900,源端口:23-TPA1EG24-17,宿网元:P782-SZ-PTN7900,宿端口:23-TPA1EG24-17,时延:-- + - 链路2:链路b, 源网元:P782-SZ-PTN7900,源端口:23-TPA1EG24-17,宿网元:P783-SZ-PTN7900,宿端口:23-TPA1EG24-17,时延:-- + 调优后: + - 隧道1:隧道b,源网元:P881-SZ-PTN7900,宿网元:P883-SZ-PTN7900,主备类型:master,状态:up,时延:20ms,丢包率:0.1%,带宽:10000bps + - 隧道路由列表: + - 链路1:链路c, 源网元:P881-SZ-PTN7900,源端口:25-TPA1EG24-11,宿网元:P882-SZ-PTN7900,宿端口:25-TPA1EG24-12,时延:10ms + - 链路2:链路d, 源网元:P882-SZ-PTN7900,源端口:25-TPA1EG24-13,宿网元:P883-SZ-PTN7900,宿端口:25-TPA1EG24-14,时延:15ms +8. 业务抢通方案执行结束时间;样例:2026-05-11T08:21:46Z; (可选) +9. 业务抢通方案执行结果,取值范围:成功、失败;举例:成功; (可选) +10. 业务抢通方案执行失败原因;业务抢通方案执行结果为“失败”时,需要填写此信息;举例:调优后隧道链路C已被占用,无法执行隧道调优 (可选) + +## 预期输出 +1. 订阅结果,取值范围:成功 +2. 订阅成功后,按照<上报通知数据格式>上报消息 \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/subscribe-incident/v1/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 new file mode 100644 index 00000000..7b631824 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/subscribe-incident/v1/en-US/template.md @@ -0,0 +1,21 @@ +## Subscription Description +Based on the following , , , and information, complete the network-side intelligent fault Incident subscription and reporting task. + +## Notification Topic +{{notification_topic}} (required) +Requirement: Provide the topic name of the intelligent fault Incident. The specific name can be Incident, Fault, intelligent fault, fault, etc. + +## Subscribe Condition +{{subscribe_condition}} (optional) +Requirement: The subscription condition includes fault priority and fault name. +Fault priority: supports passing a list. The allowed values of this parameter include critical, high, medium, and low. +Fault name: supports passing a list. The allowed values of this parameter are the list of network-side fault names, for example: pigtail fault, fiber break, board fault, optical module fault, etc. + +## Notification Data Format +{{notification_data_format}} (optional) +Requirement: 1. Reported data type: Incident, fault; 2. Reported data format: which A2A Part carries it (DataPart, TextPart). +For example: report Incident data via DataPart. + +## Expected Output +1. Subscription result, success or failure +2. Reason for subscription failure (optional) diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/subscribe_incident/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/subscribe-incident/v1/zh-CN/template.md similarity index 100% rename from a2a-t-resources/src/main/resources/prompt_resources/templates/subscribe_incident/zh-CN/template.md rename to a2a-t-resources/src/main/resources/prompt_resources/templates/Notification-T/network-layer/subscribe-incident/v1/zh-CN/template.md diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/private-line-complaint/v1/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 new file mode 100644 index 00000000..a66d5505 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/private-line-complaint/v1/en-US/template.md @@ -0,0 +1,38 @@ +## Task Type +Transport private line service complaint diagnosis + +## Task Description +Based on and , perform network-side fault root cause diagnosis in the complaint scenario, achieve the complaint diagnosis goal defined in , and return the task processing result in the structure defined in . + +## Task Target +Diagnose network-side faults and return diagnostic result information such as fault root causes and repair suggestions. + +## Task Object +{{task_object}} (required) +Requirement: Supports passing in private line service name / private line service identifier / private line service access port name. Choose one of these methods to identify the private line service object +1. Private line service name: the private line service name on the network side. Example: SZ_Aggregation_Private_Line_Service_100M_SPN (optional) +2. Private line service identifier: the unique identifier of the private line service on the network side. Example: 5571d707-5aad-11ea-8629-286ed488cff3 (optional) +3. Access port name: the access port name of the service, which can be a physical port or a logical port (sub-interface) (optional) + - Physical port format: network element name + board number + board model + port number. Example: P180-SZ-PTN7900-23-TPA1EG24-17 + - Logical port format: network element name + board number + board model + port number (cvlan=access vlan id). Example: P180-SZ-PTN7900-23-TPA1EG24-17(cvlan=100) + +## Task Context +{{task_context}} (required) +Requirement: Please provide the following information +1. Complaint category: includes two scenarios, "private line interruption" and "poor private line quality". Example: "poor private line quality" (required) +2. Problem occurrence time. Example: "2026-05-16T08:21:46Z" (optional) +3. OSS-side event sequence number: fill in the private line service complaint work order sequence number on the OSS side. Example: "event-id-20260511-09013" (required) +4. Complaint details: may include the private line user's description of the service complaint and the OSS-side preprocessing information. Example: "Starting from 8:30 this morning, the response latency from SZ to GZ suddenly increased from an average of 12ms to 320ms. Accessing the core trading system of the GZ data center is very slow. The trading interfaces of the counter and mobile banking frequently report 'connection timeout'. Normal business operations take a dozen seconds or even half a minute to return results!" (optional) + +## Expected Output +Requirement: The complaint diagnosis task result should include the following information: +1. Diagnosis result. Allowed values: success, failure (required) +2. Diagnosis result details (required) +3. Repair suggestions (optional) +4. Fault root cause list, where each fault root cause includes fault root cause name, detailed description, repair suggestions, fault root cause point location, etc. (optional) + +## Terminology Explanation +1. Private line interruption + - Synonyms: private line interruption, service interruption, network unreachable, service down, service inaccessible +2. Poor private line quality + - Synonyms: private line poor quality, service stutter, service access timeout, service packet loss, service high latency, service jitter, service congestion, service experience degradation, service high error rate, service optical power abnormal diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/private-line-complaint/v1/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 new file mode 100644 index 00000000..68630a9f --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/private-line-complaint/v1/zh-CN/template.md @@ -0,0 +1,39 @@ +## 任务类型(Task Type) +传输专线业务投诉诊断 + +## 任务描述(Task Description) +基于<任务对象>、<任务上下文> 进行投诉场景的网络侧故障根因诊断, 达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。 + +## 任务目标(Task Target) +对网络侧故障进行诊断,返回故障根因和修复建议等诊断结果信息。 + +## 任务对象(Task Object) +{{任务对象}}(必选) +要求:支持传入 专线业务名称/专线业务标识/专线业务接入端口名称,选择其中1种方式来表示专线业务对象 +1. 专线业务名称:网络侧的专线业务名称; 举例:SZ_汇聚专线业务_100M_SPN (可选) +2. 专线业务标识:网络侧的专线业务唯一标识;举例:5571d707-5aad-11ea-8629-286ed488cff3 (可选) +3. 接入端口名称:业务的接入端口名称,可以是物理端口或者逻辑端口(子接口) (可选) + - 物理端口格式为:网元名称+单板号+单板型号+端口号;举例:P180-SZ-PTN7900-23-TPA1EG24-17 + - 逻辑端口格式为:网元名称+单板号+单板型号+端口号(cvlan=接入vlanid) ;举例:P180-SZ-PTN7900-23-TPA1EG24-17(cvlan=100) + +## 任务上下文(Task Context) +{{任务上下文}}(必选) +要求:请提供如下信息 +1. 投诉分类:包含"专线中断"和"专线质差"2种场景;举例:"专线质差";(必选) +2. 问题发生时间;举例:"2026-05-16T08:21:46Z";(可选) +3. OSS侧事件流水号:填写OSS侧的专线业务投诉工单流水号;举例: "event-id-20260511-09013";(必选) +4. 投诉详情:可以包含专线用户对于业务投诉的描述,以及OSS侧的预处理信息;举例:"从今天早上8点半开始,从SZ访问GZ的响应延迟从平均12ms骤升至320ms,访问GZ机房的核心交易系统非常慢。柜面和手机银行的交易接口频繁报‘连接超时’,正常业务操作要等十几秒甚至半分钟才能返回结果!";(可选) + +## 预期输出(Expected Output) +要求投诉诊断任务的结果包含如下信息: +1. 诊断结果;参数的取值范围包括:成功、失败;(必选) +2. 诊断结果详细信息; (必选) +3. 修复建议; (可选) +4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息; (可选) + + +## 术语解释(Terminology Explanation) +1. 专线中断 + - 同义词术语:private line interruption,业务中断,网络无法连通,业务不通,业务无法访问 +2. 专线质差 + - 同义词术语:private line poor quality,业务卡顿,业务访问超时,业务丢包,业务时延大,业务抖动,业务拥塞,业务体验下滑,业务误码率高,业务光功率异常 \ No newline at end of file diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/en-US/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/en-US/template.md new file mode 100644 index 00000000..6a205377 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/en-US/template.md @@ -0,0 +1,85 @@ +## Operation Type + +{{operation_type}} (required) + +Requirement: + +1. Please provide the operation type. Allowed values: Create, Modify + +## Task Type + +Wireless network energy saving + +## Task Description + +{{task_description}} (required) + +Requirement: + +1. Please dynamically provide the task description based on (Create/Modify): + - When the type is [Create]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in , and return the wireless network energy saving result in the format defined in . + - When the type is [Modify]: Based on and , output a wireless network energy saving plan that achieves the wireless network energy saving goal defined in . + +## 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: + +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. **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). 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: + +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: + - Basic information about the intent execution result, including: + - Area + - Intent goal achievement status + - Detailed information about the intent execution result, including: + - Energy saving gain achievement status, current energy saving gain goal, actual energy saving gain percentage, target adjustment suggestions + - Rate goal achievement status, current rate goal, actual rate, target adjustment suggestions diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/zh-CN/template.md new file mode 100644 index 00000000..67866a88 --- /dev/null +++ b/a2a-t-resources/src/main/resources/prompt_resources/templates/Task-T/network-layer/ran-energy-saving/v1/zh-CN/template.md @@ -0,0 +1,85 @@ +## 操作类型(Operation Type) + +{{操作类型}}(必填) + +要求: + +1. 请提供操作类型,取值范围:创建、修改 + +## 任务类型(Task Type) + +无线网络节能 + +## 任务描述(Task Description) + +{{任务描述}}(必填) + +要求: + +1. 请根据<操作类型>(创建/修改),动态提供任务描述信息: + - 类型为【创建】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标,并按照<预期输出>中定义的格式返回无线网络节能结果。 + - 类型为【修改】时内容为:基于<任务对象>、<任务上下文>,输出无线网络节能方案,达成<任务目标>中定义的无线网络节能目标。 + +## 任务对象(Task Object) + +{{任务对象}}(必填) + +要求: + +1. 区域名称:(必填) + - 明确指定节能覆盖范围信息,地理或逻辑区域(如 区级行政单位) + - 必须是类似"松山湖管委会"这种具体且物理真实的小区名称,而不是"所有小区"、"室内小区"、"室外小区"这种无具体物理位置的描述 + - 举例:区域:"松山湖管委会" + +## 任务目标(Task Target) + +{{任务目标}}(选填) + +要求: + +1. 定义任务需达成的量化或定性目标,量化目标和定性目标不可同时选择,默认为定性目标。节能目标的具体说明如下: + - 量化目标:能耗目标;节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比;举例:能耗目标:30% + - 定性目标:在满足任务上下文的条件下进行最大化节能 + +## 任务上下文(Task Context) + +{{任务上下文}}(选填) + +要求: + +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} + + 方式二: + - 全时段速率保障目标(必填) + - 举例:速率保障目标: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. 若任务对象为地理/逻辑区域时,输出网络节能任务的意图报告,意图报告应包含以下信息: + - 意图的执行结果基本信息,包含如下内容: + - 区域 + - 意图目标达成情况 + - 意图的执行结果详情,包含如下内容: + - 节能增益达成情况、当前的节能增益目标、实际节能增益百分比、目标调整建议 + - 速率目标达成情况、当前的速率目标、实际速率、目标调整建议 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/zh-CN/template.md deleted file mode 100644 index ae7194a5..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/energy_saving/zh-CN/template.md +++ /dev/null @@ -1,44 +0,0 @@ -## 任务类型(Task Type) -无线能效优化 - -## 任务描述(Task Description) -基于<任务对象>、<任务上下文>、<约束条件>输出能效优化方案,达成<任务目标>中定义的能效优化目标,按照<预期输出>中定义的结构返回能效优化的结果。 - -## 任务目标(Task Target) -{{任务目标}}(可选) -要求:定义任务需达成的量化或定性目标,如能效目标和速率保障目标。均为可选参数。 -任务级节能的具体目标说明: -1. 能耗目标:节能的增益目标,目标值格式必须为百分比,如总功耗降低的百分比。举例:能耗目标:30% -2. 速率保障目标:需要保障的下行吞吐率目标最小值。举例:速率保障目标:50Mbps/s - -## 任务对象(Task Object) -{{任务对象}}(必选) -要求:明确指定节能区域信息,地理或逻辑区域(如:区级行政单位)或经纬度坐标。区域信息为必选参数。 -以下是具体说明: -1. 地理/逻辑的区域名称,举例:区域:“松山湖管委会”,坐标(112.0,20.6)区域 -2. **重要**:区域信息,是具体物理地址,例如:“松山湖管委会”这种物理真实小区,而不是“所有小区”、“室内小区”、“室外小区”这种无具体物理位置的描述信息 - -## 任务上下文(Task Context) -{{任务上下文}}(可选) -要求:提供任务执行的背景和上下文信息。可选参数。 - -## 约束条件(Constraints) -{{约束条件}}(可选) -要求:列出任务执行中必须遵守的规则、限制和边界条件,约束条件默认对区域内所有场景均适用,针对区域内指定场景适用的约束条件,请单独说明。均为可选参数。 -1. 需要节能的小区类型(室内/室外/所有),举例:节能的小区类型:室外 -2. 节能的小区制式(LTE/NR/全部),举例:节能的小区制式:NR -3. 对基础层和容量层的频点要求,频点的表述方式必须为下行频点号,举例:1650频点做载波关断的基础层 -4. 节能的激进程度是激进节能还是保守节能,举例:节能激进程度:激进节能 -5. 是否要求体验无损节能,举例:是否要求体验无损节能:是 -6. 需要执行节能的时间段区间范围,以天为周期循环执行,例如:需要节能的时间范围:起始时间“08:00:00”,结束时间“12:00:00” -7. 针对区域内制定场景适用的约束条件(如:铁路/黑森林/商务区/居民区),举例:商业区场景禁止1300频点做容量层;黑森林场景禁止在0-6点开通休眠 -8. 是否允许在节能时关闭载波的频点,举例:载波关断频点特殊要求:不可关断 - -## 预期输出(Expected Output) -要求:输出能效优化任务的意图报告,意图报告应包含以下信息: -### 意图报告基本信息 -1. 能效优化报告的唯一标识 -2. 节能任务唯一标识 -3. 总体意图的执行结果,执行结果包括执行结果状态,执行结果详情,如果未达成目标,描述未达成的具体原因。 -### 意图报告详细信息 -1. 具体KPI的执行结果,执行结果包括执行结果状态,执行结果详情,如果未达成目标,描述未达成的具体原因。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/zh-CN/template.md deleted file mode 100644 index fa6b8f50..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/fault_diagnosis/zh-CN/template.md +++ /dev/null @@ -1,58 +0,0 @@ -## 任务类型(Task Type) -故障根因诊断。 - - - -## 任务描述(Task Description) - -根据事件识别输出的事件信息和初步定界分析、初步根因分析、初步影响分析等信息,进行故障事件的根因定位,输出根因定位信息及建议处理方案。 - - - -## 任务目标(Task Target) - -{{任务目标}}(必选) - -要求:说明对故障诊断任务的具体要求,如完成故障事件的根因定位,输出专业根因定位信息、故障根因专业分类、故障根因详情、远程处置方案、现场抢修方案及业务影响分析。 - -样例:请进行故障事件的根因定位,输出专业根因定位信息、故障根因专业分类、故障根因详情、远程处置方案、现场抢修方案及业务影响分析。 - - - -## 任务上下文(Task Context) - -{{任务上下文}}(必选) - -要求:故障根因诊断任务可包含多条告警事件,每条事件可包含以下信息: - -1. 故障事件流水号,必选。样例:1856365516_2839324485_2130908106_4130674041 -2. 事件标题名称,必选。样例:5G基站退服事件 -3. 事件关联的告警厂家内部流水号,必选。样例:123211121244 -4. 事件关联的传输专业告警扩展流水号,可选。 -5. 事件关联告警在网管系统内唯一标识,必选。样例:0108-007-012-10-000003 -6. 事件关联告警标题,必选。样例:gNodeB退服告警 -7. 事件产生时间,必选。样例:2025-05-22 08:40:44 -8. 告警级别,数字越小越严重。一级告警(Critical);二级告警(Major);三级告警(Minor);四级告警(Warning)。必选。样例:四级告警 -9. 基站ID,无线工作台用。可选。样例:2148660 -10. 网元名称,必选。样例:泉州惠安-惠安张坂玉山灯杆G-HRHH -11. 电路号,可选。样例:泉州惠安张坂惠南联兴-泉州惠安局楼10GE1097086NR -12. 机房信息,可选。样例:泉州惠安张坂玉山一楼自建机房 - -样例:有1条待诊断的告警事件,具体内容如下: - -- 事件流水号是1856365516_2839324485_2130908106_4130674041,标题为5G基站退服事件,事件关联的告警厂家内部流水号为123211121244,事件关联告警在网管系统内唯一标识为0108-007-012-10-000003,事件关联告警标题是gNodeB退服告警,事件产生时间为2025-05-22 08:40:44,告警级别是四级,网元名称为泉州惠安-惠安张坂玉山灯杆G-HRHH。 - - - -## 预期输出(Expected Output) -{{预期输出}}(可选) - -要求:针对预期返回的故障诊断结果,需要声明如下信息: - -1. 需要声明基于A2A的哪一类Part承载,如DataPart或TextPart。 -2. 需要声明包含哪些信息要素(如诊断结果、定位结论、定位分析过程等) - -样例:要求通过TextPart返回故障诊断结果。故障诊断结果的具体内容要求如下: - -1. 故障根因定位结果的取值范围包括:成功、驳回、失败、其他。 -2. 故障根因详细信息,包括 故障根因的名称、详情、修复建议(含远程处理和现场抢修)、定位分析过程、业务影响, 故障根因点所在资源对象的标识、类型,名称,详细位置等信息。 diff --git a/a2a-t-resources/src/main/resources/prompt_resources/templates/private_line_complaint/zh-CN/template.md b/a2a-t-resources/src/main/resources/prompt_resources/templates/private_line_complaint/zh-CN/template.md deleted file mode 100644 index 5f2e951a..00000000 --- a/a2a-t-resources/src/main/resources/prompt_resources/templates/private_line_complaint/zh-CN/template.md +++ /dev/null @@ -1,33 +0,0 @@ -## 任务类型(Task Type) -SPN专线投诉诊断 - -## 任务描述(Task Description) -要求:提供任务的整体概述,基于<任务对象>、<任务上下文>、<约束条件>进行投诉场景的网络侧故障根因诊断,达成<任务目标>中定义的投诉诊断目标,按照<预期输出>中定义的结构返回任务处理结果。 - -## 任务目标(Task Target) -要求: -1. 基于专线业务的故障现象,诊断并返回网络侧的故障根因 - -## 任务对象(Task Object) -{{任务对象}}(可选) -要求:提供专线业务对象,以下 专线名称,接入端口名称,接入端口资源ID参数,3个参数下发其中一个参数用于标识专线业务对象 -- 专线名称:网络侧EMS网管的专线对象标识 -- 接入端口名称:网络侧EMS网管的接入端口名称,格式为:网元名称+单板号+单板型号+端口号 -- 接入端口资源ID:网络侧EMS网管的接入段口资源对象标识 -样例: -接入端口资源ID:f47ac10b-58cc-4372-a567-0e02b2c3d479 - -## 任务上下文(Task Context) -{{任务上下文}}(必选) -要求:提供专线业务的故障现象描述和诊断任务的上下文信息 -1. 故障现象:包含“专线中断”和“专线质量差”两种场景,必选参数。举例:”专线中断“ -2. 故障发生时间,可选参数。举例:“2024-01-16T08:21:46Z” -3. OSS侧的故障或者事件流水号,可选参数。举例:“fault-id-1-017-20230511-09013” - -## 预期输出(Expected Output) -{{预期输出}}(可选) -要求: -1. 包含诊断结果类型,诊断结果详细信息,修复建议 -2. 诊断结果类型参数的取值范围包括:诊断成功、诊断失败、诊断未开始或者诊断未结束 -3. 可以包括多个故障根因,每个故障根因包含:故障根因的名称、详细描述、修复建议,故障根因点所在资源对象的标识、类型、名称、详细位置等信息。故障整体的详细描述,故障根因的修复建议。 -4. 如果存在多故障根因,需要对多个故障根因中的详细描述和修复建议总结提炼到总体的诊断结果详情和修复建议中。 \ No newline at end of file diff --git a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java index 04677a93..f8ca33f6 100644 --- a/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java +++ b/a2a-t-resources/src/test/java/net/openan/a2at/sdk/resources/ClasspathPromptResourceLoaderTest.java @@ -1,11 +1,16 @@ 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.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 { @@ -25,29 +30,35 @@ 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("missing_scenario", "en-US", "template.md"); + 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/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("ran-energy-saving")); } @Test void loadsPackagedSubscribeIncidentSlotSchemaWithSemanticHint() { - String text = loader.loadText(new PromptResourceKey("slots", "subscribe_incident", "zh-CN", "slot.json")); + 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")); @@ -58,34 +69,35 @@ void loadsPackagedSubscribeIncidentSlotSchemaWithSemanticHint() { } @Test - void loadsPackagedFaultDiagnosisScenarioCatalogWithLatestShortExample() { - String text = loader.loadText(new PromptResourceKey("scenarios", "catalog", "zh-CN", "scenarios.json")); - - assertTrue(text.contains("\"scenario_code\": \"fault_diagnosis\"")); - assertTrue(text.contains("1856365516_2839324485_2130908106_4130674041")); - assertFalse(text.contains("task_request_id")); + 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 loadsPackagedFaultDiagnosisSlotSchemaWithLatestConciseDescriptions() { - String text = loader.loadText(new PromptResourceKey("slots", "fault_diagnosis", "zh-CN", "slot.json")); - - assertTrue(text.contains("\"type\": \"string\"")); - assertTrue(text.contains("\"required\": [")); - assertTrue(text.contains("DataPart")); - assertTrue(text.contains("TextPart")); - assertFalse(text.contains("task_request_id")); + 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)); } - @Test - void loadsPackagedFaultDiagnosisTemplateWithoutLegacyLongExamples() { - String text = loader.loadText(new PromptResourceKey("templates", "fault_diagnosis", "zh-CN", "template.md")); - - assertTrue(text.contains("Task Type")); - assertTrue(text.contains("Task Target")); - assertTrue(text.contains("DataPart")); - assertTrue(text.contains("TextPart")); - assertFalse(text.contains("Full request example")); - assertFalse(text.contains("Full response example")); + 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-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..677f02af --- /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.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.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(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(7, 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; + } + } +} diff --git a/a2a-t-sample/README.zh-CN.md b/a2a-t-sample/README.zh-CN.md index 80f5ed37..1f1e31e9 100644 --- a/a2a-t-sample/README.zh-CN.md +++ b/a2a-t-sample/README.zh-CN.md @@ -8,26 +8,286 @@ ## 入口类 -- 客户端:`net.openan.a2at.sample.client.ClientSampleMain` -- 服务端:`net.openan.a2at.sample.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` +- 授权策略样例(Authorization-T):`net.openan.a2at.sample.authz_policy.AuthzSampleMain` ## 模块内资源 -- 客户端环境模板:`sample/client/client.env.example` -- 服务端环境模板:`sample/server/server.env.example` -- 客户端场景输入:`sample/client/scenario.json` +- 服务恢复样例客户端环境模板:`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` +- 协商样例场景输入(slot schema + 参数缺失/补齐数据):`sample/negotiation/scenario.json` + +## 协商(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 报文编排 + 传输端点选择(按 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`)、协商校验 schema(`InformationNegotiationSchemas`,与 private-line 样例共用) | + +### 协商样例启动 + +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 + +# 强制阻塞端点(默认按 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`。 + +## 业务抢通(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`。 + +## 协商接口闭环评测(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 和协商上下文。 + +**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` 提取需要补充的槽位清单,再据此补槽。 +2. **客户端接受协商并补充报文后,服务端如何合并原始模板与协商补充内容?** 无增量合并 API。合并发生在客户端:客户端持有完整参数集(round-1 提取参数 + 补槽值),用 `generateTaskPromptFromDataWithSchema` **重新渲染整份 Task-T**(步骤 6),随 accept 一并发送;服务端对完整报文做 `validateTaskPromptAndDataFilling`(步骤 8)提参,不做"原始模板+增量"拼接。 + +**槽位契约**:评测直接使用 SDK 内置的 private-line-complaint 模板(不做任何资源改动)。模板将"任务上下文"定义为一个组合槽,其内部要求 4 个子项(投诉分类[必选]、问题发生时间[可选]、OSS侧事件流水号[必选]、投诉详情[可选])。评测的 `task_schema`(eval-suite.json 内)把该组合槽细化为 4 个独立子槽位用于**校验**。**生成门**(内置 slot.json 的 required)要求任务对象与任务上下文非空:任一为空即在生成期 fail-fast,协商不触发;**校验门**(调用方 schema 的 required)检出非空上下文内缺失或非法的必选子字段并触发协商。fromData 用例输入为结构化 JSON——每个 key 对应一个明确的子项值(如 `"OSS侧事件流水号": "event-id-20260511-09013"`);fromText 用例若走到补槽环节则携带 `client_data`(客户端自身掌握的结构化知识,即其文本引用过的字段),补槽重渲染基于该知识而非服务端提取结果。 + +通道语义:fromData 与 fromText 走**同一条 LLM 槽位抽取管线**(`llm_calls` 证据可见)——fromData 的抽取做归一化映射(把结构化子字段组合为模板槽值),fromText 的抽取从自然语言中提取槽位;抽取完成后模板渲染均为确定性规则。协商报文的 fromData 生成则是纯确定性渲染(类型化数据 → 模板,零 LLM 调用),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. 跑全量用例(fromData 和 fromText 两条用例轨,每个 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-D03 /path/to/.env + +# 4. 只跑其中一条用例轨:fromData 或 fromText 分开测评,报告可分别输出后横向对比 +java @a2a-t-sample/target/eval.javaargs.txt --channel fromData --out eval-report-fromdata.json /path/to/.env +java @a2a-t-sample/target/eval.javaargs.txt --channel fromText --out eval-report-fromtext.json /path/to/.env + +# 5. 强制全量走 fromText 协商生成通道(不改用例文件) +java @a2a-t-sample/target/eval.javaargs.txt --negotiation-channel fromText /path/to/.env + +# 6. 指定报告输出路径(默认 ./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` 或强制值),便于横向对比多个模型的报告。 + +## 专线投诉信息协商 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 +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`(**20 条用例,fromData(PLC-D01~D10)与 fromText(PLC-T01~T10)两条轨各 10 条**,覆盖同一场景矩阵:完整输入不触发 / 必选字段缺失(生成期拦截或触发协商)/ 双必选子字段缺失 / 可选字段缺失不触发 / 值无效(对象形态、分类枚举、流水号格式)/ 字段错位归位 / 口语化与推断 / 负例补槽被拒)。报告中每个 case 输出逐步证据(`api_calls`、`llm_calls`、生成 prompt 原文、校验判定与抽取参数、耗时),`metrics` 汇总通过率。 + +## 协商 fromData 接口专项评测(Negotiation FromData API Eval) + +`NegotiationFromDataApiEvalApp` 是**聚焦协商接口本身**的专项验证入口,验证 **6 个协商接口**(生成 3 个 + 校验提参 3 个),不涉及 Task-T 等非协商接口。每个用例是一次"生成 → 校验提参"闭环: + +**生成接口**(确定性渲染,零 LLM): +- `A2ATServer.generateNegotiationProposePromptFromData`(propose,server 角色) +- `A2ATClient.generateNegotiationAcceptPromptFromData`(accept,client 角色) +- `A2ATClient.generateNegotiationRejectPromptFromData`(reject,client 角色) + +**校验提参接口**(语义 LLM 管线),由协议中的**接收方角色**调用: +- `A2ATClient.validateProposePromptAndDataFilling`(client 校验收到的 propose) +- `A2ATServer.validateAcceptPromptAndDataFilling`(server 校验收到的 accept) +- `A2ATServer.validateRejectPromptAndDataFilling`(server 校验收到的 reject) + +**输入为传统结构化数据**:每个条目就是一个 key-value——key 是 Task-T 模板的槽位名称(必选:任务对象/投诉分类/OSS侧事件流水号;可选:问题发生时间/投诉详情),value 是原子数据值(如 `"投诉分类": "专线中断"`、`"OSS侧事件流水号": "event-id-20260602-08841"`),不含自然语言段落,不构造模板传错场景。断言分两段: + +| 阶段 | 断言 | +|---|---| +| 生成 | 报文逐字包含每个 item 的名称与值、relationship 原文、`Accept`/`Reject` 结论标记、模板 URI 匹配 | +| 校验提参 | 校验通过,且**提取出的参数与输入的 key-value 完全一致**(提参保真度) | + +**用例矩阵**(按 Task-T 字段模型的组合):propose 覆盖全部必选缺失/单必选缺失×3/双必选缺失/必选+可选/仅可选/空边界共 8 条;accept 覆盖全必选补齐/单必选补齐/全 5 字段补齐/可选补齐共 4 条;reject 覆盖单字段/双字段无法提供(字段名+原因,模板字面形态)共 2 条。用例集:`sample/negotiation/eval/fromdata-api-suite.json`。 + +运行命令(生成段零 LLM;**校验提参段走语义 LLM,需真实 key**,两段证据均在报告 `llm_calls` 中): + +```bash +mvn -pl a2a-t-sample -am -DskipTests package +java @a2a-t-sample/target/fromdata-eval.javaargs.txt --out fromdata-eval-report.json /path/to/.env +``` + +报告含每个 case 两步的 `api_calls`(方法+完整 JSON 入参)、生成的报文原文、校验提取参数、逐条断言与 `llm_calls` 证据。 + +## 授权策略(Authorization-T)演示 Demo + +授权策略 Demo 是单进程直调 SDK 示例:客户端生成 Authorization-T prompt → 服务端校验合规性并提取参数。题集按场景分组,覆盖新增/修改/删除/查询四种操作类型及各类校验失败路径: + +| 题集 | 入口 | 说明 | +|---|---|---| +| 冒烟集 `scenarios.json` | 默认题集 | 15 例,负例代表 + 多条列表 + 变异 schema 组 | +| 全量集 `scenarios-100.json` | 全量评测 | 100 例,评分基线 | +| 留出集 `scenarios-holdout-100.json` | 防过拟合 | 100 例,与全量同构 | + +入口类:`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` -2. 如需修改默认请求内容,可编辑 `sample/client/scenario.json` +2. 如需修改默认请求内容,可编辑 `sample/subscribe_incident/client/scenario.json` 3. 启动客户端: ```bash java @a2a-t-sample/target/client.javaargs.txt ``` -如果不传参数,`ClientSampleMain` 会回退到包内的 `sample/client/client.env.example`。 +如果不传参数,`ClientSampleMain` 会回退到包内的 `sample/subscribe_incident/client/client.env`。 ## 服务端启动 @@ -38,7 +298,7 @@ java @a2a-t-sample/target/client.javaargs.txt java @a2a-t-sample/target/server.javaargs.txt ``` -如果不传参数,`ServerSampleMain` 会回退到包内的 `sample/server/server.env.example`。 +如果不传参数,`ServerSampleMain` 会回退到包内的 `sample/subscribe_incident/server/server.env`。 ## Git Bash 本地调试 @@ -51,11 +311,22 @@ 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): + +```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 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..417b50d3 --- /dev/null +++ b/a2a-t-sample/docs/Negotiation-Sample-Design.md @@ -0,0 +1,154 @@ +# 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 加载器 +│ └── InformationNegotiationSchemas.java # 协商校验 caller schema(与 private-line 样例共用) +└── eval/ + ├── NegotiationEvalApp.java # 协商接口闭环评测:逐用例驱动接口并输出可回放 JSON 报告 + ├── NegotiationFromDataApiEvalApp.java # fromData 六接口专项评测 + └── EvalLlmCaptureClient.java # LLM 调用证据采集 +``` + +评测用例与场景配置同目录外置:`src/main/resources/sample/negotiation/eval/eval-suite.json`。 + +### 服务端处理流程 + +`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}` 占位符)、诊断结果行模板。Java 代码只保留通用规则(槽位遍历、编号列表拼装、占位符替换),源码中不含任何场景实例化字符串。更换演示场景只需编辑该文件。 + +## 五、运行 + +前置条件: + +- JDK 17+; +- `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)、`eval.javaargs.txt`(协商接口评测)、`fromdata-eval.javaargs.txt`(fromData 专项评测)。Windows 控制台建议加 `-Dfile.encoding=UTF-8` 前缀,避免中文日志显示为乱码(不影响输出文件本身的编码)。 + +### 端到端 demo + +```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 +``` + +组合 `--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 时启动即失败并给出指引。 + +## 七、覆盖矩阵 + +| 维度 | 覆盖 | +|---|---| +| 协商类型 | 信息协商(端到端流程);信息 / 目标 / 可行性(3×3 生成用例) | +| 协商阶段 | 提议 / 接受 / 拒绝 | +| 报文生成通道 | fromData(结构化)/ fromText(自然语言) | +| 传输端点 | message:stream / message:send | +| 参数缺失形态 | 单参数缺失、多参数缺失、值模糊、字段错位(见 scenario.json,可扩展) | +| 正确性评测 | 10 条用例(eval.javaargs.txt):触发判定、缺失槽集合、propose 出站校验、补充值抽取一致、闭环完成,含负例 | + +## 八、已知限制 + +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/pom.xml b/a2a-t-sample/pom.xml index aa170b9d..504fbe30 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 @@ -120,15 +126,56 @@ - -cp + -cp + ${sample.jar.path}${path.separator}${sample.runtime.classpath} + + -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 + + -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} +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} +net.openan.a2at.sample.negotiation.eval.NegotiationFromDataApiEvalApp + +-cp +${sample.jar.path}${path.separator}${sample.runtime.classpath} +net.openan.a2at.sample.task_t.TaskTDemoMain + + -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.server.ServerSampleMain -server.env +net.openan.a2at.sample.service_recovery.client.ClientSampleMain - -cp + -cp ${sample.jar.path}${path.separator}${sample.runtime.classpath} -net.openan.a2at.sample.client.ClientSampleMain -client.env +net.openan.a2at.sample.authz_policy.AuthzSampleMain 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/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..e211b2f0 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/SampleRunner.java @@ -0,0 +1,325 @@ +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/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..cad978dd --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptGenerator.java @@ -0,0 +1,14 @@ +package net.openan.a2at.sample.authz_policy; + +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 { + + MetadataContent generate(AuthzScenario scenario); +} 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..ba5b4c4d --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzPromptValidator.java @@ -0,0 +1,15 @@ +package net.openan.a2at.sample.authz_policy; + +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; + +/** + * Functional interface for validating an authorization prompt and extracting filled slot data. + * + * @since 2026-08 + */ +@FunctionalInterface +public interface AuthzPromptValidator { + + FilledParamData validate(String prompt, Map schema, String templateUri); +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCapture.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCapture.java new file mode 100644 index 00000000..86953ec4 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCapture.java @@ -0,0 +1,255 @@ +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.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; + +/** + * Reasoning capture apparatus for the Authorization-T demo. + * + *

When enabled ({@code -Dauthz.reasoning=true}), it decorates the underlying {@link LLMClient} to: + * + *

    + *
  • append a short reasoning-instruction appendix to the {@code slot_extraction} and {@code content_validation} + * system prompts (identified by exact fingerprint match against the classpath {@code system.md} resources); + *
  • extend the {@code content_validation} output schema with an optional {@code reasoning} property (so the + * instruction does not contradict {@code additionalProperties:false}); + *
  • best-effort parse the returned raw content for the {@code reasoning} field and record it, keyed by scenario + * label. + *
+ * + *

Unmatched stages (negotiation and other extensions) are passed through untouched with no capture. Captures are + * associated to scenarios through a {@link ThreadLocal} double-slot (label + entry list), which is correct because one + * executor task thread runs the complete client→server flow for one scenario. + * + * @since 2026-08 + */ +public final class AuthzReasoningCapture { + + private static final String SLOT_EXTRACTION_SYSTEM = "/prompt_resources/prompts/slot_extraction/zh-CN/system.md"; + private static final String CONTENT_VALIDATION_SYSTEM = + "/prompt_resources/prompts/content_validation/zh-CN/system.md"; + + private static final String REASONING_APPENDIX = "\n\n[reasoning_appendix]\n" + + "除上述要求的 JSON 字段外,请额外输出一个 \"reasoning\" 字段(字符串)," + + "简要说明本次提参/校验每个结论的依据与推理过程。" + + "该字段仅供调用方分析使用,不会影响对其他字段的判定与使用。"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final ConcurrentHashMap registry = new ConcurrentHashMap<>(); + + private final ThreadLocal currentLabel = new ThreadLocal<>(); + + private final ThreadLocal> currentCaptures = new ThreadLocal<>(); + + private String slotExtractionFingerprint; + + private String contentValidationFingerprint; + + /** + * Resolves the reasoning-capture flag from the {@code authz.reasoning} system property. + * + * @return {@code true} when reasoning capture is enabled, {@code false} by default + * @throws IllegalArgumentException if the property is set to a value other than {@code true} or {@code false} + * (case-insensitive) + * @since 2026-08 + */ + public static boolean resolveReasoningFlag() { + String raw = System.getProperty("authz.reasoning"); + if (raw == null || raw.isBlank()) { + return false; + } + String value = raw.trim(); + if ("true".equalsIgnoreCase(value)) { + return true; + } + if ("false".equalsIgnoreCase(value)) { + return false; + } + throw new IllegalArgumentException( + "Invalid authz.reasoning value: '" + raw + "'. Expected true or false (case-insensitive)."); + } + + /** + * Wraps the provided client with a reasoning-capturing decorator. + * + * @param real underlying LLM client + * @return decorated client, never {@code null} + * @since 2026-08 + */ + public LLMClient wrap(LLMClient real) { + return (messages, jsonSchema, temperature, maxTokens) -> { + Stage stage = identifyStage(messages); + if (stage == null) { + return real.structured(messages, jsonSchema, temperature, maxTokens); + } + List> modifiedMessages = appendAppendix(messages); + Map modifiedSchema = jsonSchema; + if (stage == Stage.CONTENT_VALIDATION) { + modifiedSchema = extendSchema(jsonSchema); + } + LLMResponse response = real.structured(modifiedMessages, modifiedSchema, temperature, maxTokens); + List entries = currentCaptures.get(); + if (entries != null) { + entries.add(new CaptureEntry(stage, extractReasoning(response.content()), response.content())); + } + return response; + }; + } + + /** + * Begins capture for a scenario on the current thread. + * + * @param label scenario label used as the registry key + * @since 2026-08 + */ + public void beginScenario(String label) { + currentLabel.set(label); + currentCaptures.set(new ArrayList<>()); + } + + /** + * Ends capture for the current thread's scenario, merging the recorded stages into the registry (the last response + * per stage wins across infra retries) and clearing the thread-local state. + * + * @since 2026-08 + */ + public void endScenario() { + String label = currentLabel.get(); + List entries = currentCaptures.get(); + if (label != null && entries != null) { + registry.put(label, mergeByStage(entries)); + } + currentLabel.remove(); + currentCaptures.remove(); + } + + /** + * Returns the captured reasoning for a scenario, if any. + * + * @param label scenario label + * @return the merged stage captures, or {@link Optional#empty()} when none was recorded + * @since 2026-08 + */ + public Optional capture(String label) { + return Optional.ofNullable(registry.get(label)); + } + + /** + * Merged per-stage reasoning capture for one scenario. + * + * @param clientReasoning reasoning from the slot_extraction stage, or {@code null} + * @param serverReasoning reasoning from the content_validation stage, or {@code null} + * @param clientRaw raw LLM response content from the slot_extraction stage, or {@code null} + * @param serverRaw raw LLM response content from the content_validation stage, or {@code null} + * @since 2026-08 + */ + public record StageCapture(String clientReasoning, String serverReasoning, String clientRaw, String serverRaw) {} + + private enum Stage { + SLOT_EXTRACTION, + CONTENT_VALIDATION + } + + private record CaptureEntry(Stage stage, String reasoning, String content) {} + + private Stage identifyStage(List> messages) { + if (messages == null || messages.isEmpty()) { + return null; + } + String content = messages.get(0).get("content"); + if (content == null) { + return null; + } + ensureLoaded(); + if (content.equals(slotExtractionFingerprint)) { + return Stage.SLOT_EXTRACTION; + } + if (content.equals(contentValidationFingerprint)) { + return Stage.CONTENT_VALIDATION; + } + return null; + } + + private static List> appendAppendix(List> messages) { + List> modified = new ArrayList<>(messages); + Map first = new LinkedHashMap<>(messages.get(0)); + first.put("content", first.get("content") + REASONING_APPENDIX); + modified.set(0, first); + return modified; + } + + private static Map extendSchema(Map jsonSchema) { + Map copy = new LinkedHashMap<>(jsonSchema); + Object properties = jsonSchema.get("properties"); + Map propsCopy = properties instanceof Map map + ? new LinkedHashMap<>((Map) map) + : new LinkedHashMap<>(); + propsCopy.put("reasoning", Map.of("type", "string")); + copy.put("properties", propsCopy); + return copy; + } + + private static String extractReasoning(String content) { + if (content == null) { + return null; + } + try { + Map parsed = MAPPER.readValue(content, new TypeReference<>() {}); + Object reasoning = parsed.get("reasoning"); + return reasoning instanceof String text ? text : null; + } catch (Exception e) { + return null; + } + } + + private static StageCapture mergeByStage(List entries) { + String clientReasoning = null; + String serverReasoning = null; + String clientRaw = null; + String serverRaw = null; + for (CaptureEntry entry : entries) { + if (entry.stage == Stage.SLOT_EXTRACTION) { + clientReasoning = entry.reasoning(); + clientRaw = entry.content(); + } else { + serverReasoning = entry.reasoning(); + serverRaw = entry.content(); + } + } + return new StageCapture(clientReasoning, serverReasoning, clientRaw, serverRaw); + } + + private void ensureLoaded() { + if (slotExtractionFingerprint == null) { + synchronized (this) { + if (slotExtractionFingerprint == null) { + slotExtractionFingerprint = readResource(SLOT_EXTRACTION_SYSTEM); + contentValidationFingerprint = readResource(CONTENT_VALIDATION_SYSTEM); + } + } + } + } + + private static String readResource(String path) { + try (InputStream in = AuthzReasoningCapture.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); + } + } +} 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..2c2b9425 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzSampleMain.java @@ -0,0 +1,461 @@ +package net.openan.a2at.sample.authz_policy; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.openan.a2at.sample.authz_policy.AuthzReasoningCapture.StageCapture; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; +import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; +import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; + +/** + * Entry point for the Authorization-T demo. + * + *

Loads scenarios from the resource configured via {@code authz.scenarios} (default: + * {@code sample/authz-policy/scenarios.json}), generates authorization prompts through {@link A2ATClient}, validates + * them through {@link A2ATServer} against the business-level parameter schema + * ({@code sample/authz-policy/param-schema.json}), and writes a JSON report with staged client/server expectations, + * actual behaviour and per-assertion match details. + * + * @since 2026-08 + */ +public final class AuthzSampleMain { + + private static final String DEFAULT_ENV_FILE = "authz.env"; + + private static final String BUNDLED_ENV_FILE = Path.of( + "a2a-t-sample", "src", "main", "resources", "sample", "authz-policy", "authz.env") + .toString(); + + private static final String SCENARIOS_RESOURCE = + System.getProperty("authz.scenarios", "sample/authz-policy/scenarios.json"); + + private static final String PARAM_SCHEMA_RESOURCE = "sample/authz-policy/param-schema.json"; + + private static final String DEFAULT_OUTPUT_DIR = "eval-results/authz-demo"; + + private static final String OUTDIR_PROPERTY = "authz.outdir"; + + private static final DateTimeFormatter REPORT_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + private static final List REQUIRED_LLM_KEYS = + List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY"); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private AuthzSampleMain() {} + + public static void main(String[] args) { + Path envPath = resolveEnvPath(args); + if (!hasRequiredLlmKeys(envPath)) { + System.err.println("Required LLM keys not configured in env file: " + envPath); + System.exit(1); + } + + int workers; + try { + workers = AuthzScenarioExecutor.resolveWorkers(); + } catch (IllegalArgumentException e) { + System.err.println("Usage: -Dauthz.workers= (integer >= 1, default 8). " + e.getMessage()); + System.exit(1); + return; + } + + boolean reasoning; + try { + reasoning = AuthzReasoningCapture.resolveReasoningFlag(); + } catch (IllegalArgumentException e) { + System.err.println("Usage: -Dauthz.reasoning=true|false (default false). " + e.getMessage()); + System.exit(1); + return; + } + + AuthzReasoningCapture capture = reasoning ? new AuthzReasoningCapture() : null; + + Map paramSchema = loadParamSchema(); + + List scenarios = AuthzScenarioLoader.load(SCENARIOS_RESOURCE); + String templateUri = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; + + AuthzPromptGenerator generator = buildGenerator(envPath, templateUri, capture); + AuthzPromptValidator validator = buildValidator(envPath, capture); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + long startMillis = System.currentTimeMillis(); + + List outcomes = executor.executeAll( + scenarios, + paramSchema, + templateUri, + workers, + (completedIndex, outcome, elapsedSeconds) -> { + String matchStr = outcome.result().match() ? "PASS" : "FAIL"; + System.out.printf( + "[%d/%d] %s %s (%s, %.1fs)%n", + completedIndex, + scenarios.size(), + matchStr, + scenarios.get(completedIndex - 1).label(), + outcome.result().outcome(), + elapsedSeconds); + }, + capture); + + double wallTimeSeconds = (System.currentTimeMillis() - startMillis) / 1000.0; + + for (int i = 0; i < scenarios.size(); i++) { + printScenarioReport(scenarios.get(i), outcomes.get(i)); + } + + Path outDir = Path.of(System.getProperty(OUTDIR_PROPERTY, DEFAULT_OUTPUT_DIR)); + Path reportPath = writeReport( + scenarios, outcomes, outDir, workers, wallTimeSeconds, SCENARIOS_RESOURCE, reasoning, capture); + System.out.println("Report written to: " + reportPath.toAbsolutePath().normalize()); + System.out.println(); + + List results = + outcomes.stream().map(ScenarioOutcome::result).toList(); + printSummary(scenarios, results); + System.exit(exitCode(results)); + } + + static Path resolveEnvPath(String[] args) { + if (args.length > 0) { + return Path.of(args[0]); + } + Path cwdEnv = Path.of(DEFAULT_ENV_FILE); + if (Files.exists(cwdEnv) && hasRequiredLlmKeys(cwdEnv)) { + return cwdEnv; + } + return Path.of(BUNDLED_ENV_FILE); + } + + static boolean hasRequiredLlmKeys(Path envFile) { + Map entries = new HashMap<>(); + try { + for (String line : Files.readAllLines(envFile)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int separator = trimmed.indexOf('='); + if (separator <= 0) { + continue; + } + entries.put( + trimmed.substring(0, separator).trim(), + trimmed.substring(separator + 1).trim()); + } + } catch (IOException exception) { + return false; + } + for (String key : REQUIRED_LLM_KEYS) { + if (entries.getOrDefault(key, "").isBlank()) { + return false; + } + } + return true; + } + + static Map loadParamSchema() { + try (InputStream stream = AuthzSampleMain.class.getClassLoader().getResourceAsStream(PARAM_SCHEMA_RESOURCE)) { + if (stream == null) { + throw new IllegalStateException("Param schema resource not found: " + PARAM_SCHEMA_RESOURCE); + } + return MAPPER.readValue(stream, new TypeReference>() {}); + } catch (IOException e) { + throw new IllegalStateException("Failed to load param schema: " + PARAM_SCHEMA_RESOURCE, e); + } + } + + static AuthzPromptGenerator buildGenerator(Path envPath, String templateUri, AuthzReasoningCapture capture) { + if (capture == null) { + A2ATClient client = new A2ATClient(envPath); + return scenario -> { + if (AuthzScenario.FROM_TEXT.equals(scenario.entry())) { + String text = (String) scenario.input().get("text"); + return client.generateAuthPromptFromText(text, templateUri); + } + @SuppressWarnings("unchecked") + Map data = + (Map) scenario.input().get("data"); + @SuppressWarnings("unchecked") + Map schema = + (Map) scenario.input().get("schema"); + return client.generateAuthPromptFromDataWithSchema(data, schema, templateUri); + }; + } + A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); + LLMClient real = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); + LLMClient decorated = capture.wrap(real); + DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder() + .envPath(envPath) + .config(config) + .llmClient(decorated); + ClientPromptGenerationOrchestrator orchestrator = builder.buildPromptGenerationOrchestrator(); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return scenario -> { + if (AuthzScenario.FROM_TEXT.equals(scenario.entry())) { + String text = (String) scenario.input().get("text"); + return orchestrator.generateAuthPromptFromText(text, parsedTemplateUri); + } + @SuppressWarnings("unchecked") + Map data = (Map) scenario.input().get("data"); + @SuppressWarnings("unchecked") + Map schema = (Map) scenario.input().get("schema"); + return orchestrator.generateAuthPromptFromDataWithSchema(data, schema, parsedTemplateUri); + }; + } + + static AuthzPromptValidator buildValidator(Path envPath, AuthzReasoningCapture capture) { + if (capture == null) { + A2ATServer server = new A2ATServer(envPath); + return server::validateAuthPromptAndDataFilling; + } + A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(envPath), envPath); + LLMClient real = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); + LLMClient decorated = capture.wrap(real); + DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder() + .envPath(envPath) + .config(config) + .llmClient(decorated); + ContentValidator contentValidator = builder.buildAuthContentValidator(); + return (prompt, schema, templateUri) -> + contentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + } + + /** Parses a template URI string for the typed orchestrator/content-validator seams. */ + private static TemplateUri parseTemplateUri(String templateUri) { + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); + } + + static void printScenarioReport(AuthzScenario scenario, ScenarioOutcome outcome) { + System.out.println("--- Scenario: " + scenario.label() + " ---"); + System.out.println(" Entry: " + scenario.entry()); + System.out.println("[Client]"); + AuthzExpected expected = scenario.expected(); + if (outcome.metadata() == null) { + System.out.println(" 生成结果: " + outcome.result().outcome()); + System.out.println(" 错误明细: " + outcome.result().slotErrors()); + } else { + System.out.println(" 生成结果: generated"); + System.out.println(" Prompt:"); + System.out.println(indent(outcome.metadata().promptText(), " ")); + } + System.out.println("[Server]"); + if (outcome.metadata() == null) { + System.out.println(" (跳过 - 客户端生成失败)"); + } else if (outcome.filled() == null) { + System.out.println(" 校验结果: " + outcome.result().outcome()); + System.out.println(" 校验明细: " + outcome.result().slotErrors()); + } else { + System.out.println(" 校验结果: success"); + System.out.println(" 校验明细: []"); + System.out.println(" 提参结果: " + outcome.filled().data()); + } + System.out.println("[判定] " + verdictLine(outcome.result())); + System.out.println(); + } + + static String verdictLine(ScenarioResult result) { + StringBuilder sb = new StringBuilder("match=" + result.match()); + if (result.clientPromptMatch() != null) { + sb.append(" (client: prompt=").append(result.clientPromptMatch() ? "OK" : "DRIFT"); + if (result.serverOutcomeMatch() != null) { + sb.append(" | server: outcome=").append(result.serverOutcomeMatch() ? "OK" : "FAIL"); + sb.append(" slot_errors=").append(result.serverOutcomeMatch() ? "OK" : "FAIL"); + } + if (result.serverParamsMatch() != null) { + sb.append(" params=").append(result.serverParamsMatch() ? "OK" : "FAIL"); + } + sb.append(")"); + } else { + sb.append(" (client: outcome=") + .append(result.match() ? "OK" : "FAIL") + .append(")"); + } + return sb.toString(); + } + + static String indent(String text, String prefix) { + return text.lines() + .map(line -> prefix + line) + .reduce((a, b) -> a + "\n" + b) + .orElse(""); + } + + static void printSummary(List scenarios, List results) { + long matchCount = results.stream().filter(ScenarioResult::match).count(); + long mismatchCount = results.size() - matchCount; + System.out.println("========== Summary =========="); + System.out.println(" Total: " + results.size() + ", Match: " + matchCount + ", Mismatch: " + mismatchCount); + System.out.println(" Exit Code: " + exitCode(results)); + System.out.println(); + } + + static int exitCode(List results) { + boolean allMatch = results.stream().allMatch(ScenarioResult::match); + return allMatch ? 0 : 1; + } + + static Path writeReport( + List scenarios, + List outcomes, + Path reportDir, + int workers, + double wallTimeSeconds, + String scenariosResource, + boolean reasoning, + AuthzReasoningCapture capture) { + Map report = new LinkedHashMap<>(); + report.put( + "meta", + Map.of( + "workers", workers, + "wallTimeSeconds", wallTimeSeconds, + "scenariosResource", scenariosResource, + "reasoning", reasoning)); + long matchCount = outcomes.stream().filter(o -> o.result().match()).count(); + long mismatchCount = outcomes.size() - matchCount; + report.put( + "summary", + Map.of( + "total", scenarios.size(), + "match", matchCount, + "mismatch", mismatchCount)); + List> scenarioEntries = new ArrayList<>(); + for (int i = 0; i < scenarios.size(); i++) { + AuthzScenario scenario = scenarios.get(i); + ScenarioOutcome outcome = outcomes.get(i); + Map entry = new LinkedHashMap<>(); + entry.put("label", scenario.label()); + entry.put("entry", scenario.entry()); + entry.put("expected_client", formatClientExpectation(scenario)); + entry.put("expected_server", formatServerExpectation(scenario)); + entry.put("actual_outcome", outcome.result().outcome()); + entry.put("match", outcome.result().match()); + entry.put( + "actual_slot_errors", + formatActualSlotErrors(outcome.result().slotErrors())); + entry.put( + "prompt_text", + outcome.metadata() != null ? outcome.metadata().promptText() : null); + entry.put( + "actual_params", outcome.filled() != null ? outcome.filled().data() : null); + Map assertions = new LinkedHashMap<>(); + assertions.put("client_prompt", outcome.result().clientPromptMatch()); + assertions.put("server_outcome", outcome.result().serverOutcomeMatch()); + assertions.put("server_params", outcome.result().serverParamsMatch()); + entry.put("assertions", assertions); + entry.put( + "error", + outcome.result().error() != null + ? Map.of( + "code", outcome.result().error().getCode(), + "message", outcome.result().error().getMessage()) + : null); + entry.put("warnings", outcome.result().warnings()); + if (reasoning && capture != null) { + Optional captured = capture.capture(scenario.label()); + if (captured.isPresent()) { + StageCapture sc = captured.get(); + if (sc.clientReasoning() != null) { + entry.put("client_reasoning", sc.clientReasoning()); + } + if (sc.serverReasoning() != null) { + entry.put("server_reasoning", sc.serverReasoning()); + } + } + } + scenarioEntries.add(entry); + } + report.put("scenarios", scenarioEntries); + try { + Files.createDirectories(reportDir); + String timestamp = LocalDateTime.now().format(REPORT_TIMESTAMP); + Path reportFile = reportDir.resolve("authz-report-" + timestamp + ".json"); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(reportFile.toFile(), report); + return reportFile; + } catch (IOException e) { + throw new IllegalStateException("Failed to write report to: " + reportDir, e); + } + } + + private static Map formatClientExpectation(AuthzScenario scenario) { + Map m = new LinkedHashMap<>(); + var client = scenario.expected().client(); + m.put("outcome", client.outcome()); + m.put("prompt_text", client.promptText()); + m.put("slot_errors", formatExpectedSlotErrors(client.slotErrors())); + return m; + } + + private static Map formatServerExpectation(AuthzScenario scenario) { + var server = scenario.expected().server(); + if (server == null) { + return null; + } + Map m = new LinkedHashMap<>(); + m.put("outcome", server.outcome()); + m.put("slot_errors", formatExpectedSlotErrors(server.slotErrors())); + m.put("params", server.params()); + return m; + } + + private static List> formatExpectedSlotErrors(List slotErrors) { + if (slotErrors == null || slotErrors.isEmpty()) { + return List.of(); + } + return slotErrors.stream() + .map(e -> { + Map m = new LinkedHashMap<>(); + m.put("slot_name", e.slotName()); + m.put("code", e.code()); + return m; + }) + .toList(); + } + + private static List> formatActualSlotErrors(List slotErrors) { + if (slotErrors == null || slotErrors.isEmpty()) { + return List.of(); + } + return slotErrors.stream() + .map(e -> { + Map m = new LinkedHashMap<>(); + m.put("slot_name", e.slotName()); + m.put("code", e.code()); + m.put("message", e.message()); + m.put("facts", e.facts()); + return m; + }) + .toList(); + } +} 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..85b33c35 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenario.java @@ -0,0 +1,162 @@ +package net.openan.a2at.sample.authz_policy; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; + +/** + * Immutable configuration item for a single demo scenario. + * + *

The expected outcome is staged: {@link ClientExpected} asserts the client SDK behaviour (generation failure with + * slot errors, or the exact rendered prompt text), {@link ServerExpected} asserts the server SDK behaviour (validation + * outcome, slot-level error details and the extracted parameters). + * + * @param label human-readable scenario label + * @param entry client entry point ({@code from_text} or {@code from_data_with_schema}) + * @param input scenario input data + * @param expected staged expected outcome + * @param validateSchema optional scenario-level validation schema that overrides the suite-level default parameter + * schema; {@code null} when the suite-level default applies + * @since 2026-08 + */ +public record AuthzScenario( + String label, + String entry, + Map input, + AuthzExpected expected, + Map validateSchema) { + + /** + * Convenience constructor for scenarios without a scenario-level validation schema. + * + * @param label human-readable scenario label + * @param entry client entry point ({@code from_text} or {@code from_data_with_schema}) + * @param input scenario input data + * @param expected staged expected outcome + */ + public AuthzScenario(String label, String entry, Map input, AuthzExpected expected) { + this(label, entry, input, expected, null); + } + + static final String FROM_TEXT = "from_text"; + static final String FROM_DATA_WITH_SCHEMA = "from_data_with_schema"; + static final String EXPECTED_SUCCESS = "success"; + + static final Set VALID_SERVER_OUTCOMES = Set.of( + EXPECTED_SUCCESS, + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode()); + + static final Set VALID_CLIENT_OUTCOMES = Set.of( + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + ErrorCatalog.SLOT_CONSTRAINT_VIOLATED.getCode(), + ErrorCatalog.SLOT_RULE_VIOLATION.getCode(), + ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), + ErrorCatalog.LLM_RESPONSE_INVALID.getCode(), + ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), + ErrorCatalog.SLOT_SCHEMA_NOT_FOUND.getCode(), + ErrorCatalog.TEMPLATE_RENDER_FAILED.getCode(), + ErrorCatalog.TEMPLATE_LOAD_FAILED.getCode()); + + /** + * Staged expected outcome of one scenario. + * + * @param client client-stage expectation; a non-null client outcome declares an expected generation failure (in + * which case {@code server} must be {@code null}), a null client outcome declares an expected successful + * generation whose {@code promptText} is asserted + * @param server server-stage expectation; {@code null} when the client stage is expected to fail + */ + public record AuthzExpected(ClientExpected client, ServerExpected server) {} + + /** + * Client-stage expectation. + * + * @param outcome expected client failure code; {@code null} means generation is expected to succeed + * @param promptText expected rendered prompt text; asserted with trailing-whitespace-insensitive equality when + * {@code outcome} is {@code null} + * @param slotErrors expected per-slot error details of the failed generation; may be {@code null} or empty when + * only the outcome code matters + */ + public record ClientExpected(String outcome, String promptText, List slotErrors) {} + + /** + * Server-stage expectation. + * + * @param outcome expected server outcome code ({@code success} or {@code negotiation.semantic_rejected}) + * @param slotErrors expected per-slot error details; may be {@code null} or empty when only the outcome code + * matters + * @param params expected extracted parameters; asserted as a subset match (recursive over maps and ordered lists) + * and only meaningful when {@code outcome} is {@code success} + */ + public record ServerExpected(String outcome, List slotErrors, Map params) {} + + /** + * Expected error detail for a single slot. + * + * @param slotName name of the slot expected to fail + * @param code machine-readable error code expected for this slot + */ + public record SlotErrorExpectation(String slotName, String code) {} + + /** + * Validates that the scenario configuration is well-formed. + * + * @param scenario the scenario to validate + * @throws IllegalArgumentException if the configuration is invalid + */ + public static void validate(AuthzScenario scenario) { + if (scenario == null) { + throw new IllegalArgumentException("scenario must not be null"); + } + if (scenario.input == null) { + throw new IllegalArgumentException("input must not be null"); + } + if (scenario.validateSchema != null && scenario.validateSchema.isEmpty()) { + throw new IllegalArgumentException("validateSchema must be non-empty when set"); + } + if (scenario.expected == null || scenario.expected.client() == null) { + throw new IllegalArgumentException("expected.client must not be null"); + } + ClientExpected client = scenario.expected.client(); + ServerExpected server = scenario.expected.server(); + if (client.outcome() != null) { + if (!VALID_CLIENT_OUTCOMES.contains(client.outcome())) { + throw new IllegalArgumentException("Invalid expected client outcome: " + client.outcome()); + } + if (server != null) { + throw new IllegalArgumentException("expected.server must be null when expected.client.outcome is set"); + } + } else { + if (client.promptText() == null || client.promptText().isBlank()) { + throw new IllegalArgumentException( + "expected.client.promptText must be non-blank when client outcome is not set"); + } + if (server == null || server.outcome() == null || !VALID_SERVER_OUTCOMES.contains(server.outcome())) { + throw new IllegalArgumentException( + "expected.server.outcome must be one of " + VALID_SERVER_OUTCOMES + " when client succeeds"); + } + // server.params on a non-success outcome is documentation-only (not asserted), mirroring the probe. + } + if (!FROM_TEXT.equals(scenario.entry) && !FROM_DATA_WITH_SCHEMA.equals(scenario.entry)) { + throw new IllegalArgumentException("Invalid entry: " + scenario.entry); + } + if (FROM_TEXT.equals(scenario.entry)) { + Object text = scenario.input.get("text"); + if (!(text instanceof String) || ((String) text).isBlank()) { + throw new IllegalArgumentException("from_text scenario requires a non-blank input.text"); + } + } + if (FROM_DATA_WITH_SCHEMA.equals(scenario.entry)) { + Object data = scenario.input.get("data"); + if (!(data instanceof Map) || ((Map) data).isEmpty()) { + throw new IllegalArgumentException("from_data_with_schema scenario requires a non-empty input.data"); + } + Object schema = scenario.input.get("schema"); + if (!(schema instanceof Map) || ((Map) schema).isEmpty()) { + throw new IllegalArgumentException("from_data_with_schema scenario requires a non-empty input.schema"); + } + } + } +} 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 new file mode 100644 index 00000000..c7e516ac --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutor.java @@ -0,0 +1,212 @@ +package net.openan.a2at.sample.authz_policy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.SlotValidationError; + +/** + * Concurrent scenario executor that runs multiple {@link AuthzScenario} instances in parallel using a fixed thread + * pool, while preserving input order in the returned result list. + * + *

Each task runs the complete client→server flow for a single scenario. Internal failures are caught per-task and + * converted to {@code infra.internal_error} outcomes, isolating the remaining scenarios from a single failure. + * + *

An optional {@link ProgressListener} receives per-completion callbacks (synchronized, index 1-based) and can be + * {@code null} when no progress reporting is needed. An optional {@link AuthzReasoningCapture} records LLM reasoning + * for each scenario when enabled. + * + * @since 2026-08 + */ +public final class AuthzScenarioExecutor { + + private static final int DEFAULT_WORKERS = 8; + + private final AuthzScenarioRunner runner; + + /** @param runner scenario runner (stateless, safe for concurrent reuse) */ + public AuthzScenarioExecutor(AuthzScenarioRunner runner) { + this.runner = runner; + } + + /** + * Progress listener invoked after each scenario completes. + * + *

Calls are serialized with a lock so the listener implementation does not need to be thread-safe. The callback + * runs on the worker thread and must return quickly. + * + * @param completedIndex 1-based completion order (not input index) + * @param outcome the scenario outcome + * @param elapsedSeconds wall-clock time for this scenario in seconds, non-negative + * @since 2026-08 + */ + @FunctionalInterface + public interface ProgressListener { + void onCompleted(int completedIndex, ScenarioOutcome outcome, double elapsedSeconds); + } + + /** + * Executes all scenarios concurrently and returns outcomes in the same order as the input list. + * + * @param scenarios scenarios to execute + * @param paramSchema business-level parameter schema for validation + * @param templateUri template URI for prompt generation + * @param workers maximum number of concurrent worker threads + * @param onCompleted optional progress callback; may be {@code null} + * @return outcomes in input order + * @throws IllegalArgumentException if {@code workers < 1} + * @throws IllegalStateException if the calling thread is interrupted while waiting + * @throws java.util.concurrent.RejectedExecutionException if a task cannot be accepted + * @since 2026-08 + */ + public List executeAll( + List scenarios, + Map paramSchema, + String templateUri, + int workers, + ProgressListener onCompleted) { + return executeAll(scenarios, paramSchema, templateUri, workers, onCompleted, null); + } + + /** + * Executes all scenarios concurrently with optional reasoning capture. + * + * @param scenarios scenarios to execute + * @param paramSchema business-level parameter schema for validation + * @param templateUri template URI for prompt generation + * @param workers maximum number of concurrent worker threads + * @param onCompleted optional progress callback; may be {@code null} + * @param capture optional reasoning capture; may be {@code null} + * @return outcomes in input order + * @throws IllegalArgumentException if {@code workers < 1} + * @throws IllegalStateException if the calling thread is interrupted while waiting + * @throws java.util.concurrent.RejectedExecutionException if a task cannot be accepted + * @since 2026-08 + */ + public List executeAll( + List scenarios, + Map paramSchema, + String templateUri, + int workers, + ProgressListener onCompleted, + AuthzReasoningCapture capture) { + int size = scenarios.size(); + if (size == 0) { + return List.of(); + } + int poolSize = Math.min(workers, size); + AtomicInteger nameCounter = new AtomicInteger(0); + ExecutorService pool = Executors.newFixedThreadPool(poolSize, r -> { + Thread t = new Thread(r, "authz-worker-" + nameCounter.incrementAndGet()); + t.setDaemon(false); + return t; + }); + AtomicInteger completedCounter = new AtomicInteger(0); + Object callbackLock = new Object(); + List> futures = new ArrayList<>(size); + for (int i = 0; i < size; i++) { + final int index = i; + futures.add(pool.submit(() -> { + String label = scenarios.get(index).label(); + if (capture != null) { + capture.beginScenario(label); + } + long startNanos = System.nanoTime(); + ScenarioOutcome outcome; + try { + outcome = runner.run(scenarios.get(index), paramSchema, templateUri); + } catch (Throwable t) { + outcome = buildInternalErrorOutcome(scenarios.get(index), t); + } finally { + if (capture != null) { + capture.endScenario(); + } + } + double elapsed = (System.nanoTime() - startNanos) / 1e9; + if (onCompleted != null) { + synchronized (callbackLock) { + onCompleted.onCompleted(completedCounter.incrementAndGet(), outcome, elapsed); + } + } + return outcome; + })); + } + + List outcomes = new ArrayList<>(size); + try { + for (int i = 0; i < size; i++) { + outcomes.add(futures.get(i).get()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + pool.shutdownNow(); + throw new IllegalStateException("Scenario execution interrupted", e); + } catch (ExecutionException e) { + pool.shutdownNow(); + throw new IllegalStateException("Unexpected execution exception", e); + } finally { + pool.shutdown(); + try { + if (!pool.awaitTermination(10, TimeUnit.SECONDS)) { + pool.shutdownNow(); + } + } catch (InterruptedException e) { + pool.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + return outcomes; + } + + /** + * Resolves the worker count from the {@code authz.workers} system property. + * + * @return worker count, defaulting to {@value DEFAULT_WORKERS} + * @throws IllegalArgumentException if the property is set to a non-integer or value < 1 + * @since 2026-08 + */ + public static int resolveWorkers() { + String raw = System.getProperty("authz.workers"); + if (raw == null || raw.isBlank()) { + return DEFAULT_WORKERS; + } + int workers; + try { + workers = Integer.parseInt(raw.trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid authz.workers value: '" + raw + + "'. Expected an integer >= 1 (default " + DEFAULT_WORKERS + ")."); + } + if (workers < 1) { + throw new IllegalArgumentException("Invalid authz.workers value: " + workers + + ". Expected an integer >= 1 (default " + DEFAULT_WORKERS + ")."); + } + return workers; + } + + private ScenarioOutcome buildInternalErrorOutcome(AuthzScenario scenario, Throwable t) { + return new ScenarioOutcome( + new ScenarioResult( + ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), + false, + new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), t.getMessage(), t), + List.of(new SlotValidationError( + "_llm", ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), t.getMessage())), + null, + null, + null, + List.of()), + null, + null); + } +} 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..e2c1c767 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoader.java @@ -0,0 +1,139 @@ +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; +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; + +/** + * Loads and validates demo scenarios from a JSON resource file. + * + *

The JSON file must contain a top-level {@code scenarios} array. Each scenario entry carries a staged + * {@code expected} object: {@code expected.client} with {@code outcome} (generation failure) or {@code promptText} + * (successful generation), and {@code expected.server} with {@code outcome}, {@code slot_errors} and {@code params}. + * + * @since 2026-08 + */ +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 -> parseScenario(item, resourcePath)).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 AuthzScenario parseScenario(Object item, String resourcePath) { + 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 clientObj = expectedRaw.get("client"); + if (!(clientObj instanceof Map clientRaw)) { + throw new IllegalStateException("Scenario 'expected.client' must be an object in: " + resourcePath); + } + ClientExpected client = new ClientExpected( + asNullableString(clientRaw.get("outcome")), + asNullableString(clientRaw.get("promptText")), + parseSlotErrors(clientRaw.get("slot_errors"))); + ServerExpected server = null; + Object serverObj = expectedRaw.get("server"); + if (serverObj instanceof Map serverRaw) { + @SuppressWarnings("unchecked") + Map params = + serverRaw.get("params") instanceof Map paramsRaw ? (Map) paramsRaw : null; + server = new ServerExpected( + asNullableString(serverRaw.get("outcome")), parseSlotErrors(serverRaw.get("slot_errors")), params); + } + Object validateSchemaObj = raw.get("validate_schema"); + Map validateSchema = null; + if (validateSchemaObj instanceof Map validateSchemaRaw) { + if (validateSchemaRaw.isEmpty()) { + throw new IllegalStateException( + "Scenario 'validate_schema' must be a non-empty object in: " + resourcePath); + } + @SuppressWarnings("unchecked") + Map validateSchemaMap = (Map) validateSchemaRaw; + validateSchema = validateSchemaMap; + } + AuthzExpected expected = new AuthzExpected(client, server); + return new AuthzScenario(label, entry, inputMap, expected, validateSchema); + } + + private static String asNullableString(Object raw) { + return raw instanceof String text ? text : null; + } + + 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..b7f41368 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunner.java @@ -0,0 +1,279 @@ +package net.openan.a2at.sample.authz_policy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.ContentValidationException; + +/** + * Scenario execution engine that runs a single scenario through prompt generation and validation, comparing the staged + * client/server expectations against the actual behaviour. + * + *

Assertion semantics mirror the Python probe: client {@code promptText} is compared with + * trailing-whitespace-insensitive equality only for recording ({@code clientPromptMatch}); it does not participate in + * the {@code match} verdict. Server {@code slot_errors} are subset-matched (each expected error must appear with the + * same slot name and code); server {@code params} are subset-matched recursively (maps by key, ordered lists + * element-wise, trimmed strings) and only asserted for expected server outcome {@code success}. Structural warnings + * (such as an empty policy-list section for a mutation operation) are recorded in {@code warnings} but never affect the + * {@code match} verdict. + * + * @since 2026-08 + */ +public final class AuthzScenarioRunner { + + private final AuthzPromptGenerator generator; + + private final AuthzPromptValidator validator; + + public AuthzScenarioRunner(AuthzPromptGenerator generator, AuthzPromptValidator validator) { + this.generator = generator; + this.validator = validator; + } + + public ScenarioOutcome run(AuthzScenario scenario, Map paramSchema, String templateUri) { + MetadataContent metadata; + try { + metadata = generator.generate(scenario); + } catch (A2ATError e) { + return buildClientErrorOutcome(scenario, e, null); + } catch (RuntimeException e) { + A2ATError wrapped = new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), e.getMessage(), e); + return buildClientErrorOutcome(scenario, wrapped, null); + } + + ClientExpected expectedClient = scenario.expected().client(); + boolean clientPromptMatch = + expectedClient.outcome() == null && promptTextMatch(expectedClient.promptText(), metadata.promptText()); + List warnings = detectWarnings(metadata.promptText()); + if (expectedClient.outcome() != null) { + // generation unexpectedly succeeded — no server-stage expectation applies + return new ScenarioOutcome( + new ScenarioResult( + AuthzScenario.EXPECTED_SUCCESS, + false, + null, + List.of(), + clientPromptMatch, + null, + null, + warnings), + metadata, + null); + } + + FilledParamData filled; + try { + Map effectiveSchema = + scenario.validateSchema() != null ? scenario.validateSchema() : paramSchema; + filled = validator.validate(metadata.promptText(), effectiveSchema, templateUri); + } catch (A2ATError e) { + return buildServerErrorOutcome(scenario, e, metadata, clientPromptMatch, warnings); + } catch (RuntimeException e) { + A2ATError wrapped = new A2ATError(ErrorCatalog.INFRA_INTERNAL_ERROR.getCode(), e.getMessage(), e); + return buildServerErrorOutcome(scenario, wrapped, metadata, clientPromptMatch, warnings); + } + + ServerExpected expectedServer = scenario.expected().server(); + List noErrors = List.of(); + boolean serverOutcomeMatch = + expectedServer != null && AuthzScenario.EXPECTED_SUCCESS.equals(expectedServer.outcome()); + boolean serverParamsMatch = serverOutcomeMatch + && (expectedServer.params() == null || paramsMatch(expectedServer.params(), filled.data())); + boolean match = serverOutcomeMatch && serverParamsMatch; + return new ScenarioOutcome( + new ScenarioResult( + AuthzScenario.EXPECTED_SUCCESS, + match, + null, + noErrors, + clientPromptMatch, + serverOutcomeMatch, + serverParamsMatch, + warnings), + metadata, + filled); + } + + private ScenarioOutcome buildClientErrorOutcome(AuthzScenario scenario, A2ATError error, MetadataContent metadata) { + String actualOutcome = error.getCode(); + List actualSlotErrors = extractSlotErrors(error); + ClientExpected expectedClient = scenario.expected().client(); + boolean outcomeMatch = + expectedClient.outcome() != null && expectedClient.outcome().equals(actualOutcome); + boolean slotErrorsMatch = outcomeMatch && slotErrorsMatch(expectedClient.slotErrors(), actualSlotErrors); + boolean match = outcomeMatch && slotErrorsMatch; + return new ScenarioOutcome( + new ScenarioResult(actualOutcome, match, error, actualSlotErrors, null, null, null, List.of()), + metadata, + null); + } + + private ScenarioOutcome buildServerErrorOutcome( + AuthzScenario scenario, + A2ATError error, + MetadataContent metadata, + boolean clientPromptMatch, + List warnings) { + String actualOutcome = error.getCode(); + List actualSlotErrors = extractSlotErrors(error); + ServerExpected expectedServer = scenario.expected().server(); + boolean outcomeMatch = expectedServer != null + && expectedServer.outcome() != null + && expectedServer.outcome().equals(actualOutcome); + boolean slotErrorsMatch = outcomeMatch && slotErrorsMatch(expectedServer.slotErrors(), actualSlotErrors); + boolean match = outcomeMatch && slotErrorsMatch; + return new ScenarioOutcome( + new ScenarioResult( + actualOutcome, match, error, actualSlotErrors, clientPromptMatch, outcomeMatch, null, warnings), + metadata, + null); + } + + private static List extractSlotErrors(A2ATError error) { + if (error instanceof PromptGenerationException pge) { + return pge.failedParameters(); + } + if (error instanceof ContentValidationException cve) { + return cve.errors(); + } + return List.of(); + } + + /** Trailing-whitespace-insensitive equality for the rendered prompt text. */ + static boolean promptTextMatch(String expected, String actual) { + if (expected == null || actual == null) { + return expected == actual; + } + return expected.strip().equals(actual.strip()); + } + + /** Subset match: every expected slot error must appear in the actual errors with the same name and code. */ + static boolean slotErrorsMatch(List expected, List actual) { + if (expected == null || expected.isEmpty()) { + return true; + } + for (SlotErrorExpectation expectedError : expected) { + boolean found = actual.stream() + .anyMatch(actualError -> actualError.slotName().equals(expectedError.slotName()) + && actualError.code().equals(expectedError.code())); + if (!found) { + return false; + } + } + return true; + } + + /** + * Recursive subset match for extracted parameters: maps match when every expected key is present with a + * recursively-equal value; lists match when of equal length and element-wise recursively equal; strings match after + * trimming; other values match via {@link Objects#equals}. + */ + static boolean paramsMatch(Object expected, Object actual) { + if (expected instanceof Map expectedMap) { + if (!(actual instanceof Map actualMap)) { + return false; + } + for (Map.Entry entry : expectedMap.entrySet()) { + if (!actualMap.containsKey(entry.getKey())) { + return false; + } + if (!paramsMatch(entry.getValue(), actualMap.get(entry.getKey()))) { + return false; + } + } + return true; + } + if (expected instanceof List expectedList) { + if (!(actual instanceof List actualList) || expectedList.size() != actualList.size()) { + return false; + } + for (int i = 0; i < expectedList.size(); i++) { + if (!paramsMatch(expectedList.get(i), actualList.get(i))) { + return false; + } + } + return true; + } + if (expected instanceof String expectedText) { + return actual instanceof String actualText && expectedText.strip().equals(actualText.strip()); + } + return Objects.equals(expected, actual); + } + + /** + * Extracts the content of a {@code ## heading} section from the prompt text. Returns the text between the heading + * line and the next heading (or end of string), stripped. Returns {@code null} if the heading is not found. + */ + static String extractSectionContent(String promptText, String heading) { + String marker = heading + "\n"; + int start = promptText.indexOf(marker); + if (start < 0) { + return null; + } + int contentStart = start + marker.length(); + int nextHeading = promptText.indexOf("\n## ", contentStart); + if (nextHeading < 0) { + return promptText.substring(contentStart).strip(); + } + return promptText.substring(contentStart, nextHeading).strip(); + } + + /** + * Detects structural warnings in the generated prompt text. Warnings are informational only and never affect the + * {@code match} verdict. + */ + static List detectWarnings(String promptText) { + if (promptText == null) { + return List.of(); + } + List warnings = new ArrayList<>(); + String operationTypeContent = extractSectionContent(promptText, "## 授权策略的操作类型"); + String policyListContent = extractSectionContent(promptText, "## 动网操作的授权策略列表"); + String operationType = operationTypeContent != null + ? operationTypeContent.lines().findFirst().map(String::strip).orElse("") + : ""; + boolean isMutation = + operationType.contains("新增") || operationType.contains("修改") || operationType.contains("删除"); + boolean policyListEmpty = policyListContent == null || policyListContent.isEmpty(); + if (isMutation && policyListEmpty) { + warnings.add("empty_policy_list_section"); + } + return warnings; + } + + /** + * Result of one scenario run. + * + * @param outcome actual outcome code ({@code success} or an {@code ErrorCatalog} code) + * @param match whether the actual behaviour satisfies all staged expectations + * @param error the raised error, if any + * @param slotErrors actual per-slot error details + * @param clientPromptMatch whether the client-generated prompt text matched; {@code null} when the client stage was + * expected to fail. Recorded for diagnostics only; does not affect {@code match}. + * @param serverOutcomeMatch whether the server outcome matched; {@code null} when no server stage ran + * @param serverParamsMatch whether the extracted parameters matched; {@code null} when not asserted + * @param warnings structural warnings detected during the run (e.g. empty policy-list section for a mutation + * operation); never affects {@code match} + */ + public record ScenarioResult( + String outcome, + boolean match, + A2ATError error, + List slotErrors, + Boolean clientPromptMatch, + Boolean serverOutcomeMatch, + Boolean serverParamsMatch, + List warnings) {} + + public record ScenarioOutcome(ScenarioResult result, MetadataContent metadata, FilledParamData filled) {} +} 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..5e574ae1 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/authz_policy/package-info.java @@ -0,0 +1,25 @@ +/** + * 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 match}/{@code mismatch} results with a non-zero exit code when any scenario does not match its expected + * outcome. + * + *

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 AuthzScenarioExecutor} - concurrent scenario executor with progress callback; + *
  • {@code AuthzReasoningCapture} - LLM reasoning capture apparatus; + *
  • {@code AuthzSampleMain} - entry point that wires everything together. + *
+ * + * @since 2026-08 + */ +@org.jspecify.annotations.NullUnmarked +package net.openan.a2at.sample.authz_policy; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/ClientSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/ClientSampleMain.java deleted file mode 100644 index 9628ab1f..00000000 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/ClientSampleMain.java +++ /dev/null @@ -1,58 +0,0 @@ -package net.openan.a2at.sample.client; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; -import net.openan.a2at.sample.client.flow.ClientSampleFlow; -import net.openan.a2at.sample.client.runtime.A2AJavaClientRuntime; -import net.openan.a2at.sample.client.runtime.DefaultSampleClientRuntime; -import net.openan.a2at.sample.client.runtime.SampleClientRuntime; -import net.openan.a2at.sample.client.runtime.SampleClientRuntimeFactory; -import net.openan.a2at.sample.shared.scenario.SampleScenarioLoader; - -/** - * Main entry orchestration for the client sample. - * - * @since 2026-05 - */ -public final class ClientSampleMain { - - private ClientSampleMain() { - } - - public static Path resolveEnvPath(String[] args) { - return args.length > 0 ? Path.of(args[0]) : DefaultSampleClientRuntime.resolveDefaultEnvPath(); - } - - public static List> runMain(Path envPath, SampleClientRuntimeFactory runtimeFactory) { - return runMain(envPath, runtimeFactory, null); - } - - public static List> runMain( - Path envPath, - SampleClientRuntimeFactory runtimeFactory, - Consumer logSink) { - SampleClientRuntime runtime = runtimeFactory.create(envPath); - try { - if (!(runtime instanceof A2AJavaClientRuntime a2aRuntime)) { - throw new IllegalStateException("Sample client runtime must implement A2AJavaClientRuntime"); - } - Map scenarioPayload = SampleScenarioLoader.loadClasspathScenario("sample/client/scenario.json"); - return ClientSampleFlow.runClientFlow( - scenarioPayload, - runtime.registryClient(), - runtime.promptClient(), - a2aRuntime, - runtime.endpointCache(), - logSink); - } finally { - runtime.close(); - } - } - - public static void main(String[] args) { - runMain(resolveEnvPath(args), DefaultSampleClientRuntime::new, System.out::println); - } -} - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientSampleFlow.java deleted file mode 100644 index e3ba8ddd..00000000 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientSampleFlow.java +++ /dev/null @@ -1,143 +0,0 @@ -package net.openan.a2at.sample.client.flow; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; -import net.openan.a2at.sample.client.prompt.SamplePromptClient; -import net.openan.a2at.sample.client.registry.SampleRegistryClient; -import net.openan.a2at.sample.client.request.A2AJavaRequestBuilder; -import net.openan.a2at.sample.client.request.BuiltA2AJavaRequest; -import net.openan.a2at.sample.client.runtime.A2AJavaClientRuntime; -import net.openan.a2at.sample.shared.endpoint.AgentEndpointCache; -import net.openan.a2at.sample.shared.endpoint.AgentEndpointResolver; -import net.openan.a2at.sample.shared.endpoint.ResolvedAgentEndpoint; -import net.openan.a2at.sample.shared.error.ValueErrorException; -import net.openan.a2at.sample.shared.logging.SampleLoggingFormatter; -import net.openan.a2at.sample.shared.stream.SampleStreamEventNormalizer; -import net.openan.a2at.sdk.client.model.PromptGenerationFailure; -import net.openan.a2at.sdk.client.model.PromptGenerationResult; -import org.a2aproject.sdk.client.ClientEvent; - -/** - * Core client-side orchestration for the publishable sample module. - * - * @since 2026-05 - */ -public final class ClientSampleFlow { - static final String NOTIFICATION_T_EXTENSION_URI = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; - - static final String NATURAL_LANGUAGE_PROMPT_INPUT = - "通知主题为Incident的,订阅条件为:订阅级别为critical的ETH-LOS的故障,上报通知数据格式为:DataPart"; - - private ClientSampleFlow() { - } - - public static List> runClientFlow( - Map scenarioPayload, - SampleRegistryClient registryClient, - SamplePromptClient promptClient, - A2AJavaClientRuntime a2aRuntime, - AgentEndpointCache endpointCache, - Consumer logSink) { - AgentCardQuery query = resolveAgentCardQuery(scenarioPayload); - Map agentCard = registryClient.queryAgentCardByName(query.name(), query.organization()); - emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "agentcard-result", agentCard)); - - ResolvedAgentEndpoint endpoint = AgentEndpointResolver.resolvePreferredInterface(agentCard); - endpointCache.setResolved(endpoint); - emit( - logSink, - SampleLoggingFormatter.formatStageLog( - "client", "agentcard-resolved", "url=" + endpoint.url() + " binding=" + endpoint.protocolBinding())); - - String promptInput = buildPromptInput(); - emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "prompt-input", promptInput)); - PromptGenerationResult promptResult = promptClient.generateTaskPrompt(promptInput); - emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "prompt-generation-result", promptResult)); - String promptText = requirePromptText(promptResult); - - BuiltA2AJavaRequest builtRequest = A2AJavaRequestBuilder.buildStreamRequest( - promptText, NOTIFICATION_T_EXTENSION_URI, buildRequestMetadata(scenarioPayload)); - emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "a2a-request-body", builtRequest.request())); - emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "request-headers", builtRequest.callContext().getHeaders())); - emit(logSink, SampleLoggingFormatter.formatStageLog( - "client", "request-built", "extension=" + builtRequest.callContext().getHeaders().get("A2A-Extensions"))); - - List> normalizedEvents = new ArrayList<>(); - for (ClientEvent rawEvent : a2aRuntime.sendMessage( - agentCard, builtRequest.request(), builtRequest.callContext(), logSink)) { - Map payload = A2AJavaClientEventMapper.toPayload(rawEvent); - Map normalizedEvent = SampleStreamEventNormalizer.normalize(payload); - normalizedEvents.add(normalizedEvent); - if ("status".equals(normalizedEvent.get("kind"))) { - emit(logSink, SampleLoggingFormatter.formatStageLog( - "client", "task-status", String.valueOf(normalizedEvent.get("state")))); - } else if ("message".equals(normalizedEvent.get("kind"))) { - emit(logSink, SampleLoggingFormatter.formatStageLog( - "client", "task-message", String.valueOf(normalizedEvent.get("text")))); - } else if ("artifact".equals(normalizedEvent.get("kind"))) { - emit(logSink, SampleLoggingFormatter.formatPayloadLog( - "client", "task-artifact", normalizedEvent.get("artifact"))); - } - } - return normalizedEvents; - } - - private static void emit(Consumer logSink, String message) { - if (logSink != null) { - logSink.accept(message); - } - } - - private static String requirePromptText(PromptGenerationResult promptResult) { - if (promptResult.promptText() != null) { - return promptResult.promptText(); - } - PromptGenerationFailure failure = promptResult.failure(); - String code = failure == null ? "PROMPT_GENERATION_FAILED" : failure.code(); - String message = failure == null ? "prompt generation did not produce text" : failure.message(); - throw new ValueErrorException(code + ": " + message); - } - - private static String buildRequestMetadata(Map scenarioPayload) { - return String.valueOf(scenarioPayload.getOrDefault("scenario", "")); - } - - private static String buildPromptInput() { - return NATURAL_LANGUAGE_PROMPT_INPUT; - } - - private static AgentCardQuery resolveAgentCardQuery(Map scenarioPayload) { - Object queryValue = scenarioPayload.get("agent_card_query"); - if (queryValue instanceof Map queryMap) { - String name = stringOrDefault(queryMap.get("name"), "RAN Energy Saving Agent"); - String organization = stringOrDefault(queryMap.get("organization"), "Huawei"); - if (name.isBlank()) { - throw new ValueErrorException("AgentCard query name must not be empty"); - } - return new AgentCardQuery(name, organization.isBlank() ? "Huawei" : organization); - } - - String name = stringOrDefault(scenarioPayload.get("agent_card_name"), "RAN Energy Saving Agent"); - String organization = stringOrDefault(scenarioPayload.get("organization"), "Huawei"); - if (name.isBlank()) { - throw new ValueErrorException("AgentCard query name must not be empty"); - } - return new AgentCardQuery(name, organization.isBlank() ? "Huawei" : organization); - } - - private static String stringOrDefault(Object value, String defaultValue) { - if (value == null) { - return defaultValue; - } - String resolved = String.valueOf(value).trim(); - return resolved.isEmpty() ? defaultValue : resolved; - } - - private record AgentCardQuery(String name, String organization) { - } -} - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/SampleClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/SampleClientRuntime.java deleted file mode 100644 index 53254c87..00000000 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/SampleClientRuntime.java +++ /dev/null @@ -1,24 +0,0 @@ -package net.openan.a2at.sample.client.runtime; - -import net.openan.a2at.sample.client.prompt.SamplePromptClient; -import net.openan.a2at.sample.client.registry.SampleRegistryClient; -import net.openan.a2at.sample.shared.endpoint.AgentEndpointCache; - -/** - * Runtime bundle required by the client sample main flow. - * - * @since 2026-05 - */ -public interface SampleClientRuntime extends AutoCloseable { - - SampleRegistryClient registryClient(); - - SamplePromptClient promptClient(); - - AgentEndpointCache endpointCache(); - - @Override - void close(); -} - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/NegotiationDemoApp.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/NegotiationDemoApp.java new file mode 100644 index 00000000..8787565b --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/NegotiationDemoApp.java @@ -0,0 +1,186 @@ +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) { + 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); + 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, preferStreaming); + 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, 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, preferStreaming, 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..28c9e4e4 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/client/NegotiationClient.java @@ -0,0 +1,354 @@ +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.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.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.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; +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 and send it to the agent; + *
  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 and send it; + *
  4. 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 final boolean preferStreaming; + private Client a2aClient; + + /** + * 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, 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; + } + + /** + * 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.taskSchema(), 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.taskSchema(), 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, + NegotiationPerformative.ACCEPT), + 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); + 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 and returns the raw reply event. */ + private EventKind send( + AgentCard agentCard, String extensionUri, MetadataContent content, Map contextMap) { + Map metadata = + NegotiationMessage.buildMetadata(extensionUri, content.promptText(), content.templateUri(), contextMap); + 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) { + 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) + .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 { + 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"); + } + } + + 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 (a2aClient != null) { + a2aClient.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/eval/EvalLlmCaptureClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/EvalLlmCaptureClient.java new file mode 100644 index 00000000..3617e2d8 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/EvalLlmCaptureClient.java @@ -0,0 +1,130 @@ +package net.openan.a2at.sample.negotiation.eval; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.mock.SampleLoggingLLMClient; +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; + +/** + * LLM client wrapper for the evaluator: records every structured call — the full request (messages, JSON schema, + * temperature, max tokens) and the response (content, model, usage) — into an in-memory buffer so the report can attach + * the exact prompts fed to the model to each step. + * + *

When a case fails, the recorded prompts show precisely which system prompt, slot description or schema entry the + * model saw, which is what drives reverse tuning of the prompt resources: a rejected or mis-extracted case can be + * traced to the exact prompt text instead of being guessed at. + * + *

Installed into {@link LLMClientFactory} by replacing the {@code openai} entry — the same reflective mechanism + * {@code SampleMockLlmInstaller.installLlmLogger} uses. Delegates to {@link SampleLoggingLLMClient} so the live console + * logging keeps working unchanged. + * + * @since 2026-08 + */ +public final class EvalLlmCaptureClient implements LLMClient { + + private static final List> buffer = new ArrayList<>(); + + private final LLMClient delegate; + + /** + * Creates a capturing LLM client. The supplied config is passed through to the logging delegate. + * + * @param config resolved LLM provider config + */ + public EvalLlmCaptureClient(LLMClientConfig config) { + this.delegate = new SampleLoggingLLMClient(config); + } + + /** + * Installs this client as the {@code openai} provider and keeps the console logging configured. + * + *

Must be called once at evaluator startup, before any {@code A2ATClient} or {@code A2ATServer} is constructed, + * so that every structured call of the run is captured. + */ + public static synchronized void install() { + try { + Field clientsField = LLMClientFactory.class.getDeclaredField("CLIENTS"); + clientsField.setAccessible(true); + @SuppressWarnings("unchecked") + Map> clients = + (Map>) clientsField.get(null); + clients.put("openai", EvalLlmCaptureClient.class); + SampleLoggingLLMClient.configure(false, "eval", System.out::println); + } catch (ReflectiveOperationException exception) { + throw new ValueErrorException("Failed to install the capturing LLM client", exception); + } + } + + /** Clears the capture buffer; called at the start of each case so calls never leak across cases. */ + public static void reset() { + synchronized (buffer) { + buffer.clear(); + } + } + + /** Drains the calls recorded since the last reset or drain; empty when no LLM call happened. */ + public static List> drain() { + synchronized (buffer) { + List> calls = new ArrayList<>(buffer); + buffer.clear(); + return calls; + } + } + + @Override + public LLMResponse structured( + List> messages, Map jsonSchema, Double temperature, Integer maxTokens) { + long nanos = System.nanoTime(); + try { + LLMResponse response = delegate.structured(messages, jsonSchema, temperature, maxTokens); + record(messages, jsonSchema, temperature, maxTokens, response, null, nanos); + return response; + } catch (RuntimeException error) { + // the request is recorded even when the call fails (rate limit, empty content, timeout) so the + // failing prompt is still inspectable from the report + record(messages, jsonSchema, temperature, maxTokens, null, error, nanos); + throw error; + } + } + + private static void record( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens, + LLMResponse response, + RuntimeException error, + long nanos) { + Map call = new LinkedHashMap<>(); + Map request = new LinkedHashMap<>(); + request.put("messages", messages); + request.put("json_schema", jsonSchema); + request.put("temperature", temperature); + request.put("max_tokens", maxTokens); + call.put("request", request); + if (response != null) { + Map resp = new LinkedHashMap<>(); + resp.put("content", response.content()); + resp.put("model", response.model()); + resp.put("usage", response.usage()); + call.put("response", resp); + } + if (error != null) { + Map failure = new LinkedHashMap<>(); + failure.put("type", error.getClass().getSimpleName()); + failure.put("message", String.valueOf(error.getMessage())); + call.put("error", failure); + } + call.put("duration_ms", (System.nanoTime() - nanos) / 1_000_000); + synchronized (buffer) { + buffer.add(call); + } + } +} 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..f19897c1 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationEvalApp.java @@ -0,0 +1,1357 @@ +package net.openan.a2at.sample.negotiation.eval; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.server.A2ATServer; + +/** + * Closed-loop evaluator for the negotiation prompt interfaces of the private-line-complaint scenario. + * + *

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

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

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

Usage: + * + *

{@code
+ * java @a2a-t-sample/target/eval.javaargs.txt [--out eval-report.json] [--case PLC-04] /path/to/.env
+ * }
+ * + * @since 2026-08 + */ +public final class NegotiationEvalApp { + + private static final ObjectMapper MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + private static final DateTimeFormatter TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private NegotiationEvalApp() {} + + /** + * Entry point. + * + * @param args options ({@code --out } report file, {@code --case } repeatable case filter), then the + * {@code .env} path carrying a real LLM API key + */ + public static void main(String[] args) { + Path envPath = null; + Path outPath = Path.of("eval-report.json"); + List caseFilter = new ArrayList<>(); + String negotiationChannelOverride = null; + String channelFilter = null; + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + if ("--out".equals(arg) && i + 1 < args.length) { + outPath = Path.of(args[++i]); + } else if ("--case".equals(arg) && i + 1 < args.length) { + caseFilter.add(args[++i]); + } else if ("--negotiation-channel".equals(arg) && i + 1 < args.length) { + negotiationChannelOverride = args[++i]; + } else if ("--channel".equals(arg) && i + 1 < args.length) { + channelFilter = args[++i]; + } else if (!arg.startsWith("--")) { + envPath = Path.of(arg); + } + } + if (envPath == null) { + System.err.println( + "Usage: java @a2a-t-sample/target/eval.javaargs.txt [--out eval-report.json] [--case PLC-04]" + + " [--channel fromData|fromText] [--negotiation-channel fromData|fromText]" + + " /path/to/.env"); + System.exit(1); + } + + EvalLlmCaptureClient.install(); + Map suite = loadSuite(); + if (negotiationChannelOverride != null) { + // run the whole suite with one negotiation generation channel without duplicating cases + for (Map testCase : cases(suite)) { + testCase.put("negotiation_channel", negotiationChannelOverride); + } + } + + Map report = new LinkedHashMap<>(); + report.put("suite", suite.get("suite")); + report.put("description", suite.get("description")); + report.put("scenario", suite.get("scenario")); + report.put("generated_at", LocalDateTime.now().format(TIMESTAMP)); + report.put("llm", llmInfo(envPath)); + report.put("case_filter", caseFilter); + report.put("channel_filter", channelFilter == null ? "all" : channelFilter); + report.put("negotiation_channel", negotiationChannelOverride == null ? "per-case" : negotiationChannelOverride); + + List> cases = new ArrayList<>(); + report.put("cases", cases); + int index = 0; + int total = countCases(suite, caseFilter, channelFilter); + for (Map testCase : cases(suite)) { + String caseId = String.valueOf(testCase.get("id")); + if (!caseFilter.isEmpty() && !caseFilter.contains(caseId)) { + continue; + } + // evaluate one task-generation channel in isolation: only its cases run, so fromData and + // fromText can be measured (and reported) separately + if (channelFilter != null && !channelFilter.equals(String.valueOf(testCase.get("channel")))) { + continue; + } + index++; + emit("\n[eval] ===== [" + index + "/" + total + "] case " + caseId + ": " + testCase.get("intent") + + " ====="); + long startNanos = System.nanoTime(); + Map record = runCase(envPath, suite, testCase); + record.put("duration_seconds", round1((System.nanoTime() - startNanos) / 1_000_000_000.0)); + cases.add(record); + report.put("metrics", metrics(cases)); + writeReport(report, outPath); + } + + report.put("metrics", metrics(cases)); + writeReport(report, outPath); + Map metrics = metrics(cases); + emit("\n[eval] ===== Negotiation interface correctness metrics ====="); + emit("[eval] cases: " + metrics.get("cases")); + for (String key : List.of( + "trigger_accuracy", + "missing_slot_exact_match", + "propose_outbound_valid", + "accept_fill_value_match", + "closed_loop_completion")) { + emit("[eval] " + metrics.get(key)); + } + emit("[eval] report written to " + outPath.toAbsolutePath()); + } + + // -- one case: the negotiation interface closed loop with full evidence capture -- + + /** Runs one case end to end and returns its replayable trace record. */ + private static Map runCase(Path envPath, Map suite, Map testCase) { + EvalLlmCaptureClient.reset(); + String caseId = String.valueOf(testCase.get("id")); + String channel = String.valueOf(testCase.get("channel")); + boolean fromText = "fromText".equals(channel); + String negotiationChannel = String.valueOf(testCase.getOrDefault("negotiation_channel", "fromData")); + boolean negotiationFromText = "fromText".equals(negotiationChannel); + String inputText = String.valueOf(testCase.get("input_text")); + Map inputData = asMap(testCase.get("input_data")); + + String taskTemplate = String.valueOf(suite.get("template_uri")); + Map taskSchema = asMap(suite.get("task_schema")); + Map phrasing = stringMap(suite.get("negotiation_phrasing")); + Map properties = asMap(taskSchema.get("properties")); + Set requiredSlots = new TreeSet<>(stringList(taskSchema.get("required"))); + Map fillValues = + mergeFills(stringMap(suite.get("client_fill_values")), stringMap(testCase.get("fill_override"))); + + Map expect = asMap(testCase.get("expect")); + boolean expectNegotiation = Boolean.TRUE.equals(expect.get("negotiation_needed")); + Set expectMissing = new TreeSet<>(stringList(expect.get("missing_slots"))); + Boolean expectFillCompletes = (Boolean) expect.get("fill_completes"); + boolean expectGenerationFails = Boolean.TRUE.equals(expect.get("generation_fails")); + + Map record = new LinkedHashMap<>(); + record.put("case", caseId); + record.put("channel", channel); + record.put("negotiation_channel", negotiationChannel); + record.put("intent", testCase.get("intent")); + Map input = new LinkedHashMap<>(); + input.put("fromText", fromText); + if (fromText) { + input.put("text", inputText); + } else { + input.put("data", inputData); + } + record.put("input", input); + record.put("task_schema", taskSchema); + Map expectJson = new LinkedHashMap<>(); + expectJson.put("negotiation_needed", expectNegotiation); + expectJson.put("missing_slots", new ArrayList<>(expectMissing)); + expectJson.put("fill_completes", expectFillCompletes); + expectJson.put("generation_fails", expectGenerationFails); + record.put("expect", expectJson); + List> steps = new ArrayList<>(); + record.put("steps", steps); + + // -- step 1: Task-T prompt generation from the extension metadata value -- + Map taskGenerationInput = new LinkedHashMap<>(); + if (fromText) { + taskGenerationInput.put("text", inputText); + } else { + taskGenerationInput.put("data", inputData); + taskGenerationInput.put("schema", taskSchema); + } + taskGenerationInput.put("template_uri", taskTemplate); + String taskGenerationApi = + fromText ? "A2ATClient.generateTaskPromptFromText" : "A2ATClient.generateTaskPromptFromDataWithSchema"; + String taskPrompt; + try { + long nanos = System.nanoTime(); + A2ATClient client = new A2ATClient(envPath); + MetadataContent taskContent = fromText + ? client.generateTaskPromptFromText(inputText, taskTemplate) + : client.generateTaskPromptFromDataWithSchema(inputData, taskSchema, taskTemplate); + taskPrompt = taskContent.promptText(); + Map step = step("1. Task-T generation (" + channel + ")", "client"); + api(step, apiCall(taskGenerationApi, taskGenerationInput)); + step.put("generated_prompt", taskPrompt); + step.put("template_uri", taskContent.templateUri()); + steps.add(step); + emit("[eval] [1] Task-T prompt generated (" + channel + ", " + secs(nanos) + "s)"); + } catch (A2ATError error) { + steps.add(errorStep( + "1. Task-T generation (" + channel + ")", + "task_generation", + error, + apiCall(taskGenerationApi, taskGenerationInput))); + emit("[eval] [1] Task-T generation FAILED: " + error.getCode() + " " + error.getMessage()); + if (expectGenerationFails) { + // generation-time fail-fast is the expected outcome (e.g. a required slot missing from the + // natural-language input): the task never exists, so negotiation cannot and must not trigger + Map verdict = new LinkedHashMap<>(); + verdict.put("trigger_correct", !expectNegotiation); + verdict.put("slots_correct", expectMissing.isEmpty()); + verdict.put("propose_outbound_valid", null); + verdict.put("accept_fill_value_match", null); + verdict.put("fill_completes_correct", null); + verdict.put("pass", !expectNegotiation && expectMissing.isEmpty()); + verdict.put( + "reason", "generation failed fast as expected: " + error.getCode() + " " + error.getMessage()); + record.put("verdict", verdict); + emit("[eval] verdict: PASS (generation fail-fast as expected)"); + return record; + } + return finish( + record, steps, null, null, null, null, null, expectNegotiation, expectMissing, expectFillCompletes); + } + + // -- step 2: server validation of the Task-T prompt -> missing-slot detection -- + Map taskValidationInput = Map.of( + "prompt", taskPrompt, + "schema", taskSchema, + "template_uri", taskTemplate); + Set actualMissing; + Map extractedRound1 = new LinkedHashMap<>(); + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData params = server.validateTaskPromptAndDataFilling(taskPrompt, taskSchema, taskTemplate); + actualMissing = requiredMissing(blankSlots(params), requiredSlots); + if (params.data() != null) { + extractedRound1.putAll(params.data()); + } + Map step = stepOf( + "2. server Task-T validation (missing-slot detection)", + "server", + validationJson("passed", null, null, params, actualMissing)); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", taskValidationInput)); + steps.add(step); + emit("[eval] [2] validation passed, missing slots: " + actualMissing + " (" + secs(nanos) + "s)"); + } catch (ContentValidationException error) { + actualMissing = requiredMissing(errorSlots(error), requiredSlots); + Map step = stepOf( + "2. server Task-T validation (missing-slot detection)", + "server", + validationJson("rejected", error.getCode(), error.errors(), null, actualMissing)); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", taskValidationInput)); + steps.add(step); + emit("[eval] [2] validation rejected (" + error.getCode() + "), missing slots: " + actualMissing); + } + + boolean negotiationTriggered = !actualMissing.isEmpty(); + if (!negotiationTriggered) { + Map done = step("3. no negotiation needed -> flow completes directly", "server"); + done.put("task_state", "COMPLETED"); + steps.add(done); + emit("[eval] [3] no missing parameters -> COMPLETED without negotiation"); + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + null, + null, + null, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + + // -- step 3: Negotiation-T propose generation from the detected missing slots -- + List missingItems = new ArrayList<>(); + for (String slot : actualMissing) { + missingItems.add(new NegotiationItem(slot, missingHint(phrasing, properties, slot))); + } + String proposeRelationship = missingItems.size() > 1 ? phrasing.get("propose_relationship") : null; + NegotiationContext proposeContext = new NegotiationContext( + UUID.randomUUID().toString(), + 1, + NegotiationContext.DEFAULT_MAX_ROUNDS, + NegotiationPerformative.PROPOSE); + String proposeInputText = itemsToProposeText(missingItems, phrasing); + Map proposeGenerationInput = new LinkedHashMap<>(); + if (negotiationFromText) { + proposeGenerationInput.put("text", proposeInputText); + proposeGenerationInput.put("context", contextJson(proposeContext)); + } else { + Map proposeData = new LinkedHashMap<>(); + proposeData.put("context", contextJson(proposeContext)); + proposeData.put("items", itemsJson(missingItems)); + if (proposeRelationship != null) { + proposeData.put("relationship", proposeRelationship); + } + proposeGenerationInput.put("data", proposeData); + } + proposeGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_PROPOSE); + String proposeGenerationApi = negotiationFromText + ? "A2ATServer.generateNegotiationProposePromptFromText" + : "A2ATServer.generateNegotiationProposePromptFromData"; + String proposePrompt; + try { + A2ATServer server = new A2ATServer(envPath); + MetadataContent propose = negotiationFromText + ? server.generateNegotiationProposePromptFromText( + proposeInputText, proposeContext, DemoTemplates.NEGOTIATION_PROPOSE) + : server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + proposeContext, new InformationProposeContent(missingItems, proposeRelationship)), + DemoTemplates.NEGOTIATION_PROPOSE); + proposePrompt = propose.promptText(); + Map step = + step("3. Negotiation-T propose generation (" + negotiationChannel + ")", "server"); + api(step, apiCall(proposeGenerationApi, proposeGenerationInput)); + step.put("negotiation_items", itemsJson(missingItems)); + if (negotiationFromText) { + step.put("input_text", proposeInputText); + } + step.put("generated_prompt", proposePrompt); + step.put("template_uri", propose.templateUri()); + step.put("task_state", "INPUT_REQUIRED"); + steps.add(step); + emit("[eval] [3] propose rendered for " + actualMissing + " (" + negotiationChannel + + ") -> INPUT_REQUIRED"); + } catch (A2ATError error) { + steps.add(errorStep( + "3. Negotiation-T propose generation (" + negotiationChannel + ")", + "propose_generation", + error, + apiCall(proposeGenerationApi, proposeGenerationInput))); + emit("[eval] [3] propose generation FAILED: " + error.getCode() + " " + error.getMessage()); + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + null, + null, + null, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + + // -- step 4: outbound validation of the generated propose -- + Map proposeValidationInput = Map.of( + "prompt", + proposePrompt, + "context", + contextJson(proposeContext), + "schema", + negotiationSchema(actualMissing, taskSchema), + "template_uri", + DemoTemplates.NEGOTIATION_PROPOSE); + Boolean proposeValid = null; + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData proposeParams = server.validateProposePromptAndDataFilling( + proposePrompt, + proposeContext, + negotiationSchema(actualMissing, taskSchema), + DemoTemplates.NEGOTIATION_PROPOSE); + proposeValid = true; + Map step = stepOf( + "4. outbound propose validation", + "server", + validationJson("passed", null, null, proposeParams, Set.of())); + api(step, apiCall("A2ATServer.validateProposePromptAndDataFilling", proposeValidationInput)); + steps.add(step); + emit("[eval] [4] outbound propose validation passed (" + secs(nanos) + "s)"); + } catch (A2ATError error) { + proposeValid = false; + List slotErrors = error + instanceof + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException + extraction + ? extraction.getErrors() + : List.of(); + Map step = stepOf( + "4. outbound propose validation", + "server", + validationJson("rejected", error.getCode(), slotErrors, null, Set.of())); + api(step, apiCall("A2ATServer.validateProposePromptAndDataFilling", proposeValidationInput)); + steps.add(step); + emit("[eval] [4] outbound propose validation REJECTED: " + error.getCode() + " " + error.getMessage()); + } + + // -- step 5: client validates the INBOUND propose and reads out the requested slots -- + // The SDK exposes the validate* API symmetrically on client and server: the receiving client re-validates + // the negotiation message it got and extracts which slots the server asked it to supplement. The fill round + // below is driven by this client-side view (not by the server's internal missing-slot set), so the report + // shows what a real client would learn from the propose message alone. + Set clientRequestedSlots = new TreeSet<>(); + try { + long nanos = System.nanoTime(); + A2ATClient client = new A2ATClient(envPath); + FilledParamData requested = client.validateProposePromptAndDataFilling( + proposePrompt, + proposeContext, + negotiationSchema(actualMissing, taskSchema), + DemoTemplates.NEGOTIATION_PROPOSE); + if (requested.data() != null) { + for (String key : requested.data().keySet()) { + // the SDK merges the negotiation context (id/round/maxRounds) into the extracted data; only the + // real slot names tell the client what to supplement + if (!"id".equals(key) && !"round".equals(key) && !"maxRounds".equals(key)) { + clientRequestedSlots.add(key); + } + } + } + Map step = stepOf( + "5. client inbound propose validation (requested-slot extraction)", + "client", + validationJson("passed", null, null, requested, Set.of())); + api( + step, + apiCall( + "A2ATClient.validateProposePromptAndDataFilling", + Map.of( + "prompt", + proposePrompt, + "context", + contextJson(proposeContext), + "schema", + negotiationSchema(actualMissing, taskSchema), + "template_uri", + DemoTemplates.NEGOTIATION_PROPOSE))); + step.put("client_requested_slots", new ArrayList<>(clientRequestedSlots)); + steps.add(step); + emit("[eval] [5] client extracted requested slots: " + clientRequestedSlots + " (" + secs(nanos) + "s)"); + } catch (A2ATError error) { + List slotErrors = error + instanceof + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException + extraction + ? extraction.getErrors() + : List.of(); + Map step = stepOf( + "5. client inbound propose validation (requested-slot extraction)", + "client", + validationJson("rejected", error.getCode(), slotErrors, null, Set.of())); + api( + step, + apiCall( + "A2ATClient.validateProposePromptAndDataFilling", + Map.of( + "prompt", + proposePrompt, + "context", + contextJson(proposeContext), + "schema", + negotiationSchema(actualMissing, taskSchema), + "template_uri", + DemoTemplates.NEGOTIATION_PROPOSE))); + step.put("client_requested_slots", List.of()); + steps.add(step); + emit("[eval] [5] client propose validation REJECTED: " + error.getCode() + " " + error.getMessage() + + " -> falling back to the server-side missing-slot set for the fill"); + } + Set slotsToFill = clientRequestedSlots.isEmpty() ? actualMissing : clientRequestedSlots; + + // -- step 6: client fills the requested slots and renders the Negotiation-T accept -- + // the filled Task-T prompt is RE-GENERATED from the complete parameter map, exactly like the demo renders + // message 3's Task-T from the filled params — not text-appended. There is no incremental merge API: the + // client holds the full parameter set (the fromData input, or for fromText the parameters the server + // extracted in round 1), completed with the fill values for everything still missing, and re-renders the + // whole Task-T prompt from that map. + Map fills = fills(slotsToFill, fillValues); + // the fill round re-renders from the CLIENT's own knowledge: for fromData the structured input, for + // fromText the client_data the case carries (what its text quoted) — falling back to the server's + // round-1 extraction, which is only available when that validation passed + Map baseData = fromText ? clientBaseData(testCase, extractedRound1) : inputData; + // only the negotiated slots' fill values enter the merge: round-1 base values are preserved verbatim, + // so the re-rendered prompt carries exactly what the client knew plus what it supplemented + Map filledData = mergeInputData(baseData, taskSchema, fills); + String filledPrompt; + Map filledGenerationInput = Map.of( + "data", filledData, + "schema", taskSchema, + "template_uri", taskTemplate); + try { + A2ATClient client = new A2ATClient(envPath); + filledPrompt = client.generateTaskPromptFromDataWithSchema(filledData, taskSchema, taskTemplate) + .promptText(); + } catch (A2ATError error) { + // a fill value violates a slot constraint: fromData generation fails fast — the closed loop cannot + // complete, which is the expected negative outcome for constraint-violating fills + filledPrompt = appendSupplements(taskPrompt, fills); + Map step = step("6. client fill + Negotiation-T accept generation (fromData)", "client"); + api(step, apiCall("A2ATClient.generateTaskPromptFromDataWithSchema", filledGenerationInput)); + step.put("client_fill", fills); + step.put( + "fill_generation_rejected", + Map.of( + "code", error.getCode(), + "message", String.valueOf(error.getMessage()))); + steps.add(step); + emit("[eval] [6] filled Task-T generation REJECTED (" + error.getCode() + "): " + error.getMessage()); + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + proposeValid, + null, + false, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + String acceptPrompt; + NegotiationContext acceptContext = new NegotiationContext( + UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, NegotiationPerformative.ACCEPT); + String acceptInputText = itemsToAcceptText(filledItems(fills), phrasing); + Map acceptGenerationInput = new LinkedHashMap<>(); + if (negotiationFromText) { + acceptGenerationInput.put("text", acceptInputText); + acceptGenerationInput.put("context", contextJson(acceptContext)); + } else { + acceptGenerationInput.put( + "data", + Map.of( + "context", contextJson(acceptContext), + "conclusion", NegotiationConclusion.ACCEPT.toString(), + "items", itemsJson(filledItems(fills)))); + } + acceptGenerationInput.put("template_uri", DemoTemplates.NEGOTIATION_ACCEPT); + String acceptGenerationApi = negotiationFromText + ? "A2ATClient.generateNegotiationAcceptPromptFromText" + : "A2ATClient.generateNegotiationAcceptPromptFromData"; + try { + A2ATClient client = new A2ATClient(envPath); + List filledItems = filledItems(fills); + MetadataContent accept = negotiationFromText + ? client.generateNegotiationAcceptPromptFromText( + acceptInputText, acceptContext, DemoTemplates.NEGOTIATION_ACCEPT) + : client.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData( + acceptContext, + new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems)), + DemoTemplates.NEGOTIATION_ACCEPT); + acceptPrompt = accept.promptText(); + Map step = + step("6. client fill + Negotiation-T accept generation (" + negotiationChannel + ")", "client"); + api( + step, + apiCall("A2ATClient.generateTaskPromptFromDataWithSchema", filledGenerationInput), + apiCall(acceptGenerationApi, acceptGenerationInput)); + step.put("client_fill", fills); + step.put("filled_task_prompt", filledPrompt); + step.put("filled_params", filledData); + if (negotiationFromText) { + step.put("input_text", acceptInputText); + } + step.put("generated_prompt", acceptPrompt); + step.put("template_uri", accept.templateUri()); + steps.add(step); + emit("[eval] [6] client fills " + fills.keySet() + ", accept rendered (" + negotiationChannel + ")"); + } catch (A2ATError error) { + steps.add(errorStep( + "6. client fill + Negotiation-T accept generation (" + negotiationChannel + ")", + "accept_generation", + error, + apiCall(acceptGenerationApi, acceptGenerationInput))); + emit("[eval] [6] accept generation FAILED: " + error.getCode() + " " + error.getMessage()); + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + proposeValid, + null, + null, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + + // -- step 7: inbound validation of the accept message; extracted values must match the client fill -- + Map acceptValidationInput = Map.of( + "prompt", + acceptPrompt, + "context", + contextJson(acceptContext), + "schema", + negotiationSchema(slotsToFill, taskSchema), + "template_uri", + DemoTemplates.NEGOTIATION_ACCEPT); + Boolean acceptValid = null; + Boolean fillValueMatch = null; + Map extractedAcceptParams = null; + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( + acceptPrompt, + acceptContext, + negotiationSchema(slotsToFill, taskSchema), + DemoTemplates.NEGOTIATION_ACCEPT); + acceptValid = true; + extractedAcceptParams = acceptParams.data() == null ? Map.of() : new LinkedHashMap<>(acceptParams.data()); + fillValueMatch = fillValuesMatch(extractedAcceptParams, fills); + Map validation = validationJson("passed", null, null, acceptParams, Set.of()); + validation.put("expected_fill", fills); + validation.put("fill_value_match", fillValueMatch); + Map step = stepOf("7. inbound accept validation", "server", validation); + api(step, apiCall("A2ATServer.validateAcceptPromptAndDataFilling", acceptValidationInput)); + steps.add(step); + emit("[eval] [7] accept validation passed, fill value match: " + fillValueMatch + " (" + secs(nanos) + + "s)"); + } catch (A2ATError error) { + acceptValid = false; + fillValueMatch = false; + List slotErrors = error + instanceof + net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException + extraction + ? extraction.getErrors() + : List.of(); + Map validation = validationJson("rejected", error.getCode(), slotErrors, null, Set.of()); + validation.put("expected_fill", fills); + validation.put("fill_value_match", false); + Map step = stepOf("7. inbound accept validation", "server", validation); + api(step, apiCall("A2ATServer.validateAcceptPromptAndDataFilling", acceptValidationInput)); + steps.add(step); + emit("[eval] [7] accept validation REJECTED: " + error.getCode() + " " + error.getMessage()); + } + + // -- step 8: second Task-T validation of the filled prompt -> COMPLETED or stays INPUT_REQUIRED -- + Map secondValidationInput = Map.of( + "prompt", filledPrompt, + "schema", taskSchema, + "template_uri", taskTemplate); + Boolean fillCompleted = null; + try { + long nanos = System.nanoTime(); + A2ATServer server = new A2ATServer(envPath); + FilledParamData filled = server.validateTaskPromptAndDataFilling(filledPrompt, taskSchema, taskTemplate); + Set stillMissing = requiredMissing(blankSlots(filled), requiredSlots); + fillCompleted = stillMissing.isEmpty(); + Map step = stepOf( + "8. second Task-T validation (fill round)", + "server", + validationJson( + fillCompleted ? "passed" : "rejected", + fillCompleted ? null : "slots_still_missing", + fillCompleted ? null : stillMissingErrors(stillMissing), + filled, + stillMissing)); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", secondValidationInput)); + step.put("task_state", fillCompleted ? "COMPLETED" : "INPUT_REQUIRED"); + steps.add(step); + emit("[eval] [8] second Task-T validation " + + (fillCompleted ? "-> COMPLETED" : "still missing: " + stillMissing) + " (" + secs(nanos) + "s)"); + } catch (ContentValidationException error) { + fillCompleted = false; + Map step = stepOf( + "8. second Task-T validation (fill round)", + "server", + validationJson("rejected", error.getCode(), error.errors(), null, errorSlots(error))); + api(step, apiCall("A2ATServer.validateTaskPromptAndDataFilling", secondValidationInput)); + steps.add(step); + emit("[eval] [8] second Task-T validation REJECTED (" + error.getCode() + "): " + errorSlots(error)); + } + + return finish( + record, + steps, + negotiationTriggered, + actualMissing, + proposeValid, + fillValueMatch, + fillCompleted, + expectNegotiation, + expectMissing, + expectFillCompletes); + } + + // -- record assembly -- + + private static Map finish( + Map record, + List> steps, + Boolean triggered, + Set actualMissing, + Boolean proposeValid, + Boolean fillValueMatch, + Boolean fillCompleted, + boolean expectNegotiation, + Set expectMissing, + Boolean expectFillCompletes) { + boolean error = triggered == null; + boolean triggerCorrect = !error && triggered == expectNegotiation; + boolean slotsCorrect = !error && actualMissing != null && actualMissing.equals(expectMissing); + Boolean proposeValidCorrect = error || proposeValid == null ? null : proposeValid; + // value match is only meaningful for cases whose fill round is expected to succeed; a negative fill case + // (expectFillCompletes=false) is judged by the fill round NOT completing — the accept validation rejecting + // the constraint-violating fill value is exactly the expected outcome, not a mismatch + Boolean fillMatchCorrect = + error || fillValueMatch == null || Boolean.FALSE.equals(expectFillCompletes) ? null : fillValueMatch; + Boolean fillCorrect; + if (error) { + fillCorrect = false; + } else if (expectFillCompletes == null) { + fillCorrect = null; + } else if (Boolean.TRUE.equals(triggered)) { + fillCorrect = expectFillCompletes.equals(fillCompleted); + } else { + fillCorrect = !expectFillCompletes; + } + boolean pass = triggerCorrect + && slotsCorrect + && (proposeValidCorrect == null || proposeValidCorrect) + && (fillMatchCorrect == null || fillMatchCorrect) + && (fillCorrect == null || fillCorrect); + + Map actual = new LinkedHashMap<>(); + actual.put("negotiation_triggered", triggered); + actual.put("missing_slots", actualMissing == null ? null : new ArrayList<>(actualMissing)); + actual.put("propose_outbound_valid", proposeValid); + actual.put("accept_fill_value_match", fillValueMatch); + actual.put("closed_loop_completed", fillCompleted); + record.put("actual", actual); + + Map verdict = new LinkedHashMap<>(); + verdict.put("trigger_correct", triggerCorrect); + verdict.put("slots_correct", slotsCorrect); + verdict.put("propose_outbound_valid", proposeValidCorrect); + verdict.put("accept_fill_value_match", fillMatchCorrect); + verdict.put("fill_completes_correct", fillCorrect); + verdict.put("pass", pass); + verdict.put( + "reason", + reason( + error, + triggerCorrect, + slotsCorrect, + fillMatchCorrect, + fillCorrect, + actualMissing, + expectMissing, + triggered, + expectNegotiation, + proposeValid)); + record.put("verdict", verdict); + + emit("[eval] verdict: " + (pass ? "PASS" : "FAIL") + + " | trigger=" + triggerCorrect + " slots=" + slotsCorrect + + " proposeValid=" + proposeValidCorrect + " fillMatch=" + fillMatchCorrect + " fill=" + fillCorrect); + return record; + } + + private static String reason( + boolean error, + boolean triggerCorrect, + boolean slotsCorrect, + Boolean fillMatchCorrect, + Boolean fillCorrect, + Set actualMissing, + Set expectMissing, + Boolean triggered, + boolean expectNegotiation, + Boolean proposeValid) { + if (error) { + return "evaluation aborted by an error (see steps)"; + } + List notes = new ArrayList<>(); + if (!triggerCorrect) { + notes.add("negotiation expected=" + expectNegotiation + " actual=" + triggered); + } + if (!slotsCorrect && actualMissing != null) { + Set missed = new TreeSet<>(expectMissing); + missed.removeAll(actualMissing); + Set extra = new TreeSet<>(actualMissing); + extra.removeAll(expectMissing); + notes.add("missing-slot set mismatch: expected=" + expectMissing + " actual=" + actualMissing + + (missed.isEmpty() ? "" : " (not reported: " + missed + ")") + + (extra.isEmpty() ? "" : " (unexpected: " + extra + ")")); + } + if (proposeValid != null && !proposeValid) { + notes.add("generated propose failed outbound validation (step 4)"); + } + if (fillMatchCorrect != null && !fillMatchCorrect) { + notes.add("server-side extracted fill values differ from what the client sent (step 7)"); + } + if (fillCorrect != null && !fillCorrect) { + notes.add("fill round did not complete as expected (step 8)"); + } + return String.join("; ", notes); + } + + // -- trace step builders -- + + private static Map step(String name, String role) { + Map step = new LinkedHashMap<>(); + step.put("step", name); + step.put("role", role); + attachLlmCalls(step); + return step; + } + + /** + * Attaches the LLM calls recorded since the previous step was built: the exact messages (system + user prompt) and + * JSON schema fed to the model, plus the response or the failure. When a case fails, this is what makes the cause + * traceable — the recorded prompt shows which slot description, constraint or system instruction the model actually + * saw, so the prompt resources can be tuned in reverse. + */ + private static void attachLlmCalls(Map step) { + List> calls = EvalLlmCaptureClient.drain(); + if (!calls.isEmpty()) { + step.put("llm_calls", calls); + } + } + + /** + * Attaches the SDK API call evidence (method name + input parameters) to a step; one step may make several calls. + */ + @SafeVarargs + private static void api(Map step, Map... calls) { + step.put("api_calls", List.of(calls)); + } + + /** One SDK API invocation record: the exact facade method plus the parameters it was called with. */ + private static Map apiCall(String method, Map input) { + Map call = new LinkedHashMap<>(); + call.put("method", method); + call.put("input", input); + return call; + } + + /** Negotiation context as report JSON (the same object handed to the generate/validate calls). */ + private static Map contextJson(NegotiationContext context) { + Map json = new LinkedHashMap<>(); + json.put("id", context.id()); + json.put("round", context.round()); + json.put("maxRounds", context.maxRounds()); + json.put("performative", context.performative().name()); + return json; + } + + private static Map stepOf(String name, String role, Map validation) { + Map step = step(name, role); + step.put("validation", validation); + return step; + } + + private static Map validationJson( + String outcome, + String errorCode, + List slotErrors, + FilledParamData params, + Set missingSlots) { + Map validation = new LinkedHashMap<>(); + validation.put("outcome", outcome); + if (errorCode != null) { + validation.put("error_code", errorCode); + } + if (slotErrors != null && !slotErrors.isEmpty()) { + List> errors = new ArrayList<>(); + for (SlotValidationError slotError : slotErrors) { + errors.add(Map.of( + "slot", nullToEmpty(slotError.slotName()), + "code", nullToEmpty(slotError.code()), + "message", nullToEmpty(slotError.message()))); + } + validation.put("slot_errors", errors); + } + if (params != null) { + validation.put("extracted_params", params.data() == null ? Map.of() : new LinkedHashMap<>(params.data())); + } + validation.put("missing_slots", missingSlots == null ? List.of() : new ArrayList<>(missingSlots)); + return validation; + } + + private static List stillMissingErrors(Set stillMissing) { + List errors = new ArrayList<>(); + for (String slot : stillMissing) { + errors.add(new SlotValidationError(slot, "slots_still_missing", "slot still missing after the fill round")); + } + return errors; + } + + private static Map errorStep( + String name, String stage, A2ATError error, Map... calls) { + Map step = step(name, "n/a"); + step.put( + "error", + Map.of( + "stage", stage, + "code", error.getCode(), + "message", String.valueOf(error.getMessage()))); + if (calls.length > 0) { + api(step, calls); + } + return step; + } + + private static List> itemsJson(List items) { + List> list = new ArrayList<>(); + for (NegotiationItem item : items) { + list.add(Map.of("name", item.name(), "value", nullToEmpty(item.value()))); + } + return list; + } + + // -- negotiation helpers -- + + /** Missing-item hint from the phrasing template ({@code {slot}}/{@code {description}} placeholders). */ + private static String missingHint(Map phrasing, Map properties, String slot) { + String template = phrasing.getOrDefault("missing_item_hint", "{slot}"); + Object description = null; + Object property = properties.get(slot); + if (property instanceof Map slotSchema + && slotSchema.get("description") instanceof String text + && !text.isBlank()) { + description = text; + } + return template.replace("{slot}", slot) + .replace("{description}", description == null ? "" : String.valueOf(description)); + } + + /** + * Assembles the natural-language propose input for the fromText negotiation channel, mirroring the demo's + * {@code FromTextStrategy}: scenario-configured prefix, numbered item list, relationship sentence. + */ + private static String itemsToProposeText(List items, Map phrasing) { + StringBuilder text = new StringBuilder(phrasing.getOrDefault("from_text_propose_prefix", "")); + appendNumberedItems(text, items); + String relationship = items.size() > 1 ? phrasing.get("propose_relationship") : null; + if (relationship != null && !relationship.isBlank()) { + text.append(relationship); + } + return text.toString(); + } + + /** + * Assembles the natural-language accept input for the fromText negotiation channel: scenario-configured prefix, + * numbered item list, suffix sentence. + */ + private static String itemsToAcceptText(List items, Map phrasing) { + StringBuilder text = new StringBuilder(phrasing.getOrDefault("from_text_accept_prefix", "")); + appendNumberedItems(text, items); + text.append(phrasing.getOrDefault("from_text_accept_suffix", "")); + return text.toString(); + } + + /** Generic numbered-list rule: {@code N. name:value;} per item, value omitted when blank. */ + private static void appendNumberedItems(StringBuilder text, List items) { + for (int i = 0; i < items.size(); i++) { + NegotiationItem item = items.get(i); + text.append(i + 1).append(". ").append(item.name()); + if (item.value() != null && !item.value().isBlank()) { + text.append(":").append(item.value()); + } + text.append(";"); + } + } + + /** + * The client's own structured knowledge for the fill round of a fromText case: the fields its input text quoted. + * The server's round-1 extraction is the fallback when the case carries no client data — a lossy fallback, because + * those params are only produced when round-1 validation passed. + */ + private static Map clientBaseData( + Map testCase, Map extractedRound1) { + Map clientData = asMap(testCase.get("client_data")); + return clientData.isEmpty() ? extractedRound1 : clientData; + } + + /** The supplement values the simulated client supplies, keyed by slot. */ + private static Map fills(Set missingSlots, Map fillValues) { + Map fills = new LinkedHashMap<>(); + for (String slot : missingSlots) { + String value = fillValues.get(slot); + if (value != null && !value.isBlank()) { + fills.put(slot, value); + } + } + return fills; + } + + private static Map mergeFills(Map base, Map override) { + Map merged = new LinkedHashMap<>(base); + merged.putAll(override); + return merged; + } + + private static List filledItems(Map fills) { + List items = new ArrayList<>(); + for (Map.Entry entry : fills.entrySet()) { + items.add(new NegotiationItem(entry.getKey(), entry.getValue())); + } + return items; + } + + /** + * Builds the complete parameter map for the fill round: every slot declared by the task schema carries either the + * round-1 input value or the client's fill value, so the filled Task-T prompt can be regenerated from data. + */ + private static Map mergeInputData( + Map inputData, Map taskSchema, Map fills) { + Map merged = new LinkedHashMap<>(); + Map properties = asMap(taskSchema.get("properties")); + for (String slot : properties.keySet()) { + Object base = inputData.get(slot); + Object value = base == null || (base instanceof String s && s.isBlank()) ? fills.get(slot) : base; + merged.put(slot, value == null ? "" : value); + } + merged.putAll(fills); + return merged; + } + + /** Appends the supplement values to a prompt verbatim; fallback when regeneration is impossible. */ + private static String appendSupplements(String taskPrompt, Map fills) { + StringBuilder filled = new StringBuilder(taskPrompt); + for (Map.Entry entry : fills.entrySet()) { + filled.append(System.lineSeparator()) + .append(entry.getKey()) + .append(":") + .append(entry.getValue()); + } + return filled.toString(); + } + + /** + * Parameter schema for the negotiation-message validations (steps 4/5/7): one property per negotiated slot, derived + * from the task schema so the description and {@code x-a2at-value-constraint} of each slot flow into the validation + * prompt — the extraction surface is exactly the supplemented information, with the same semantic constraints the + * Task-T validation enforces. + */ + private static Map negotiationSchema(Set slots, Map taskSchema) { + Map taskProperties = asMap(taskSchema.get("properties")); + Map properties = new LinkedHashMap<>(); + for (String slot : slots) { + Map property = new LinkedHashMap<>(asMap(taskProperties.get(slot))); + if (property.isEmpty()) { + property.put("type", "string"); + } + properties.put(slot, property); + } + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + schema.put("properties", properties); + schema.put("required", new ArrayList<>(slots)); + return schema; + } + + /** + * Whether every server-extracted fill value matches the value the client sent for that slot. The comparison is + * containment in either direction after whitespace removal: the extracted value may carry the slot's field label + * (e.g. 接入端口名称:P781-…) or only the bare value (e.g. P781-…), both of which faithfully transport the fill. + */ + private static boolean fillValuesMatch(Map extracted, Map fills) { + for (Map.Entry entry : fills.entrySet()) { + Object value = extracted.get(entry.getKey()); + if (value == null) { + return false; + } + String extractedText = normalize(String.valueOf(value)); + String sentText = normalize(entry.getValue()); + if (!extractedText.contains(sentText) && !sentText.contains(extractedText)) { + return false; + } + } + return true; + } + + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("\\s+", ""); + } + + /** Slots whose extracted value is null/blank (negotiation candidates). */ + private static Set blankSlots(FilledParamData params) { + Set blank = new TreeSet<>(); + if (params != null && params.data() != null) { + for (Map.Entry entry : params.data().entrySet()) { + Object value = entry.getValue(); + if (value == null || (value instanceof String s && s.isBlank())) { + blank.add(entry.getKey()); + } + } + } + return blank; + } + + /** + * Restricts missing-slot candidates to the task schema's required slots: an empty optional slot is a valid state + * and never a negotiation trigger — only a required slot that is blank (or flagged by the semantic validator) means + * information the negotiation must ask for. + */ + private static Set requiredMissing(Set candidates, Set requiredSlots) { + Set missing = new TreeSet<>(candidates); + missing.retainAll(requiredSlots); + return missing; + } + + /** Slots reported as errors by the semantic validation. */ + private static Set errorSlots(ContentValidationException error) { + Set slots = new TreeSet<>(); + if (error.errors() != null) { + for (SlotValidationError validationError : error.errors()) { + if (validationError.slotName() != null + && !validationError.slotName().isBlank()) { + slots.add(validationError.slotName()); + } + } + } + return slots; + } + + // -- metrics -- + + private static Map metrics(List> cases) { + int total = cases.size(); + int triggerOk = 0; + int slotsOk = 0; + int proposeEvaluated = 0; + int proposeOk = 0; + int fillMatchEvaluated = 0; + int fillMatchOk = 0; + int loopEvaluated = 0; + int loopOk = 0; + int pass = 0; + for (Map record : cases) { + Map verdict = (Map) record.get("verdict"); + if (Boolean.TRUE.equals(verdict.get("trigger_correct"))) { + triggerOk++; + } + if (Boolean.TRUE.equals(verdict.get("slots_correct"))) { + slotsOk++; + } + if (verdict.get("propose_outbound_valid") != null) { + proposeEvaluated++; + if (Boolean.TRUE.equals(verdict.get("propose_outbound_valid"))) { + proposeOk++; + } + } + if (verdict.get("accept_fill_value_match") != null) { + fillMatchEvaluated++; + if (Boolean.TRUE.equals(verdict.get("accept_fill_value_match"))) { + fillMatchOk++; + } + } + if (verdict.get("fill_completes_correct") != null) { + loopEvaluated++; + if (Boolean.TRUE.equals(verdict.get("fill_completes_correct"))) { + loopOk++; + } + } + if (Boolean.TRUE.equals(verdict.get("pass"))) { + pass++; + } + } + Map metrics = new LinkedHashMap<>(); + metrics.put("cases", total); + metrics.put("trigger_accuracy", ratio("negotiation correctly triggered/held", triggerOk, total)); + metrics.put("missing_slot_exact_match", ratio("missing-slot set exact match", slotsOk, total)); + metrics.put( + "propose_outbound_valid", + ratio("generated propose passes outbound validation", proposeOk, proposeEvaluated)); + metrics.put( + "accept_fill_value_match", + ratio("extracted fill values match client fill", fillMatchOk, fillMatchEvaluated)); + metrics.put("closed_loop_completion", ratio("closed loop completes as expected", loopOk, loopEvaluated)); + metrics.put("full_pass", ratio("all dimensions pass", pass, total)); + return metrics; + } + + private static Map ratio(String label, int part, int total) { + Map ratio = new LinkedHashMap<>(); + ratio.put("label", label); + ratio.put("passed", part); + ratio.put("total", total); + ratio.put("pct", total == 0 ? 0.0 : round1(100.0 * part / total)); + return ratio; + } + + // -- suite loading and small utilities -- + + private static Map loadSuite() { + try (InputStream stream = NegotiationEvalApp.class + .getClassLoader() + .getResourceAsStream("sample/negotiation/eval/eval-suite.json")) { + if (stream == null) { + throw new IllegalStateException("Eval suite resource not found"); + } + return MAPPER.readValue(stream, new TypeReference>() {}); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to read eval suite", exception); + } + } + + /** LLM run configuration from the env file, without the API key. */ + private static Map llmInfo(Path envPath) { + Map values = readEnv(envPath); + Map info = new LinkedHashMap<>(); + info.put("provider", values.getOrDefault("A2AT_LLM_PROVIDER", "")); + info.put("model", values.getOrDefault("A2AT_LLM_MODEL", "")); + info.put("base_url", values.getOrDefault("A2AT_LLM_BASE_URL", "")); + return info; + } + + private static Map readEnv(Path envPath) { + Map values = new LinkedHashMap<>(); + if (envPath == null || !Files.exists(envPath)) { + return values; + } + try { + for (String rawLine : Files.readAllLines(envPath)) { + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { + continue; + } + int separator = line.indexOf('='); + values.put( + line.substring(0, separator).trim(), + line.substring(separator + 1).trim()); + } + } catch (IOException exception) { + throw new UncheckedIOException("Failed to read env file: " + envPath, exception); + } + return values; + } + + private static void writeReport(Map report, Path outPath) { + try { + MAPPER.writeValue(outPath.toFile(), report); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to write eval report: " + outPath, exception); + } + } + + private static int countCases(Map suite, List filter, String channelFilter) { + return (int) cases(suite).stream() + .filter(testCase -> filter.isEmpty() || filter.contains(String.valueOf(testCase.get("id")))) + .filter(testCase -> + channelFilter == null || channelFilter.equals(String.valueOf(testCase.get("channel")))) + .count(); + } + + /** Negotiation template URIs used by the closed loop; kept local so the evaluator stays self-contained. */ + private static final class DemoTemplates { + + static final String NEGOTIATION_PROPOSE = "Negotiation-T/information-negotiation/propose/v1"; + + static final String NEGOTIATION_ACCEPT = "Negotiation-T/information-negotiation/accept-reject/v1"; + + private DemoTemplates() {} + } + + @SuppressWarnings("unchecked") + private static List> cases(Map suite) { + Object raw = suite.get("cases"); + return raw instanceof List list ? (List>) raw : List.of(); + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object value) { + return value instanceof Map map ? (Map) map : Map.of(); + } + + private static Map stringMap(Object value) { + Map result = new LinkedHashMap<>(); + if (value instanceof Map map) { + map.forEach((key, val) -> result.put(String.valueOf(key), String.valueOf(val))); + } + return result; + } + + private static List stringList(Object value) { + if (value instanceof List list) { + return list.stream().map(String::valueOf).toList(); + } + return List.of(); + } + + private static String nullToEmpty(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static double round1(double value) { + return Math.round(value * 10.0) / 10.0; + } + + private static String secs(long startNanos) { + return String.valueOf(round1((System.nanoTime() - startNanos) / 1_000_000_000.0)); + } + + private static void emit(String message) { + System.out.println(message); + } +} 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 new file mode 100644 index 00000000..a211db4e --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalApp.java @@ -0,0 +1,526 @@ +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.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; +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.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; + +/** + * Focused capability evaluator for the six Negotiation-T fromData interfaces: + * + *
    + *
  • generation - {@code generateNegotiationProposePromptFromData}, {@code generateNegotiationAcceptPromptFromData}, + * {@code generateNegotiationRejectPromptFromData}; + *
  • validation and parameter extraction - {@code validateProposePromptAndDataFilling}, + * {@code validateAcceptPromptAndDataFilling}, {@code validateRejectPromptAndDataFilling}. + *
+ * + *

Each case is one generate-validate round trip over pure structured data: every item is one key (the Task-T + * template slot name from the private-line-complaint scenario) carrying an atomic value - no natural-language passages, + * no template-error scenarios. The generated message is validated through the matching validate interface (propose + * validated client-side, accept/reject server-side - the receiving role of the protocol), and the extracted parameters + * must carry back exactly the input key-value pairs. + * + *

Generation is deterministic rendering (no LLM call); validation runs the SDK negotiation semantic pipeline (one + * LLM call), so a real API key is needed and each validation is recorded as {@code llm_calls} evidence in the report. + * The case matrix follows the Task-T field model (required: 任务对象/投诉分类/OSS侧事件流水号; optional: 问题发生时间/投诉详情). + * + *

Usage: + * + *

{@code
+ * java @a2a-t-sample/target/fromdata-eval.javaargs.txt [--out fromdata-eval-report.json] /path/to/.env
+ * }
+ * + * @since 2026-08 + */ +public final class NegotiationFromDataApiEvalApp { + + 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 static final String SUITE_RESOURCE = "sample/negotiation/eval/fromdata-api-suite.json"; + + private static final Set CONTEXT_KEYS = Set.of("id", "round", "maxRounds"); + + private static Consumer sink = System.out::println; + + private NegotiationFromDataApiEvalApp() {} + + /** Entry point. */ + public static void main(String[] args) { + Path envPath = null; + Path outPath = Path.of("fromdata-eval-report.json"); + 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 (!arg.startsWith("--")) { + envPath = Path.of(arg); + } + } + if (envPath == null) { + System.err.println( + "Usage: java @a2a-t-sample/target/fromdata-eval.javaargs.txt [--out fromdata-eval-report.json]" + + " /path/to/.env"); + System.exit(1); + } + + EvalLlmCaptureClient.install(); + 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)); + + List> cases = new ArrayList<>(); + report.put("cases", cases); + int index = 0; + List> suiteCases = asMapList(suite.get("cases")); + for (Map testCase : suiteCases) { + index++; + emit("\n[fromdata-eval] [" + index + "/" + suiteCases.size() + "] case " + testCase.get("id") + ": " + + testCase.get("intent")); + long startNanos = System.nanoTime(); + Map record = runCase(envPath, testCase); + record.put("duration_seconds", Math.round((System.nanoTime() - startNanos) / 100_000_000.0) / 10.0); + cases.add(record); + writeReport(report, outPath); + } + report.put("metrics", metrics(cases)); + writeReport(report, outPath); + emit("\n[fromdata-eval] report written to " + outPath.toAbsolutePath()); + } + + // -- one case: generate -> validate round trip with exact assertions -- + + private static Map runCase(Path envPath, Map testCase) { + String caseId = String.valueOf(testCase.get("id")); + String api = String.valueOf(testCase.get("api")); + Map input = asMap(testCase.get("input")); + Map expect = asMap(testCase.get("expect")); + boolean expectSuccess = Boolean.TRUE.equals(expect.get("succeeds")); + + Map itemsInput = asMap(input.get("items")); + String relationship = input.containsKey("relationship") ? String.valueOf(input.get("relationship")) : null; + String template = resolveTemplate(api, input); + + NegotiationPerformative performative = "propose".equals(api) + ? NegotiationPerformative.PROPOSE + : "accept".equals(api) ? NegotiationPerformative.ACCEPT : NegotiationPerformative.REJECT; + NegotiationContext context = new NegotiationContext( + UUID.randomUUID().toString(), 1, NegotiationContext.DEFAULT_MAX_ROUNDS, performative); + List items = new ArrayList<>(); + for (Map.Entry entry : itemsInput.entrySet()) { + items.add(new NegotiationItem(entry.getKey(), String.valueOf(entry.getValue()))); + } + + String generateMethod = "propose".equals(api) + ? "A2ATServer.generateNegotiationProposePromptFromData" + : "A2ATClient.generateNegotiation" + ("accept".equals(api) ? "Accept" : "Reject") + "PromptFromData"; + String validateMethod = "propose".equals(api) + ? "A2ATClient.validateProposePromptAndDataFilling" + : "A2ATServer.validate" + ("accept".equals(api) ? "Accept" : "Reject") + "PromptAndDataFilling"; + // the receiving role validates: the client validates the server's propose, the server validates the + // client's accept/reject - exactly the role assignment of the negotiation protocol + String generateRole = "propose".equals(api) ? "server" : "client"; + String validateRole = "propose".equals(api) ? "client" : "server"; + + Map generateInput = new LinkedHashMap<>(); + generateInput.put("context", contextJson(context)); + generateInput.put("items", itemsInput); + if (input.containsKey("relationship")) { + generateInput.put("relationship", relationship); + } + generateInput.put("template_uri", template); + + Map record = new LinkedHashMap<>(); + record.put("case", caseId); + record.put("intent", testCase.get("intent")); + record.put("steps", new ArrayList>()); + record.put("input", generateInput); + + List> checks = new ArrayList<>(); + record.put("checks", checks); + + // -- step 1: generation from structured data -- + EvalLlmCaptureClient.reset(); + Map generateStep = step("1. generate " + api + " (fromData)", generateRole); + api(generateStep, apiCall(generateMethod, generateInput)); + String prompt = null; + try { + MetadataContent content = + switch (api) { + case "propose" -> new A2ATServer(envPath) + .generateNegotiationProposePromptFromData( + new NegotiationProposeData( + context, new InformationProposeContent(items, relationship)), + template); + case "accept" -> new A2ATClient(envPath) + .generateNegotiationAcceptPromptFromData( + new NegotiationEndingData( + context, + new InformationEndingContent(NegotiationConclusion.ACCEPT, items)), + template); + case "reject" -> new A2ATClient(envPath) + .generateNegotiationRejectPromptFromData( + new NegotiationEndingData( + context, + new InformationEndingContent(NegotiationConclusion.REJECT, items)), + template); + default -> throw new IllegalArgumentException("Unknown api: " + api); + }; + prompt = content.promptText(); + generateStep.put("generated_prompt", prompt); + generateStep.put("template_uri", content.templateUri()); + generateStep.put("extension_uri", content.extensionUri()); + NegotiationContext emitted = content.negotiationContext(); + if (emitted != null) { + generateStep.put("emitted_context", contextJson(emitted)); + } + if (expectSuccess) { + String normalized = normalize(prompt); + for (Map.Entry entry : itemsInput.entrySet()) { + check( + checks, + "prompt contains item name: " + entry.getKey(), + normalized.contains(normalize(entry.getKey()))); + check( + checks, + "prompt contains item value: " + entry.getKey(), + normalized.contains(normalize(String.valueOf(entry.getValue())))); + } + if (input.containsKey("relationship") && !relationship.isBlank()) { + check(checks, "prompt contains relationship", normalized.contains(normalize(relationship))); + } + for (String marker : stringList(expect.get("contains"))) { + check(checks, "prompt contains marker: " + marker, normalized.contains(normalize(marker))); + } + if (expect.get("template") != null) { + check( + checks, + "template uri matches", + String.valueOf(expect.get("template")).equals(content.templateUri())); + } + // the wire context carries the performative of the emitted message and keeps the session identity + check( + checks, + "emitted context performative matches the api", + emitted != null && performative.equals(emitted.performative())); + check( + checks, + "emitted context keeps the session id", + emitted != null && context.id().equals(emitted.id())); + } else { + check(checks, "expected generation failure but succeeded", false); + } + } catch (RuntimeException error) { + generateStep.put("error", errorJson(error)); + if (expectSuccess) { + check( + checks, + "expected generation success but failed: " + + error.getClass().getSimpleName(), + false); + } else { + check(checks, "generation failed as expected", true); + } + } + attachLlmCalls(generateStep); + steps(record).add(generateStep); + + // -- step 2: validation and parameter extraction of the generated message -- + if (prompt != null && !itemsInput.isEmpty()) { + // 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)); + validateInput.put("schema", extractionSchema); + validateInput.put("template_uri", template); + Map validateStep = step("2. validate + extract " + api + " (fromData)", validateRole); + api(validateStep, apiCall(validateMethod, validateInput)); + try { + FilledParamData params = + switch (api) { + case "propose" -> new A2ATClient(envPath) + .validateProposePromptAndDataFilling(prompt, context, extractionSchema, template); + case "accept" -> new A2ATServer(envPath) + .validateAcceptPromptAndDataFilling(prompt, context, extractionSchema, template); + case "reject" -> new A2ATServer(envPath) + .validateRejectPromptAndDataFilling(prompt, context, extractionSchema, template); + default -> throw new IllegalArgumentException("Unknown api: " + api); + }; + Map extracted = params.data() == null ? Map.of() : new LinkedHashMap<>(params.data()); + Map extractedJson = new LinkedHashMap<>(); + for (Map.Entry entry : extracted.entrySet()) { + if (!CONTEXT_KEYS.contains(entry.getKey())) { + extractedJson.put(entry.getKey(), entry.getValue()); + } + } + Map validation = new LinkedHashMap<>(); + validation.put("outcome", "passed"); + validation.put("extracted_params", extractedJson); + validateStep.put("validation", validation); + check(checks, "validation passed", true); + checkExtractedFields(checks, api, extractedJson, itemsInput, input); + } catch (RuntimeException error) { + Map validation = new LinkedHashMap<>(); + validation.put("outcome", "rejected"); + validation.put("error", errorJson(error)); + validateStep.put("validation", validation); + check(checks, "validation passed (got: " + error.getClass().getSimpleName() + ")", false); + } + attachLlmCalls(validateStep); + steps(record).add(validateStep); + } + + boolean pass = checks.stream().allMatch(check -> Boolean.TRUE.equals(check.get("pass"))); + Map verdict = new LinkedHashMap<>(); + verdict.put("pass", pass); + List failed = new ArrayList<>(); + for (Map check : checks) { + if (!Boolean.TRUE.equals(check.get("pass"))) { + failed.add(String.valueOf(check.get("name"))); + } + } + verdict.put("reason", failed.isEmpty() ? "" : "failed checks: " + String.join("; ", failed)); + record.put("verdict", verdict); + emit("[fromdata-eval] verdict: " + (pass ? "PASS" : "FAIL") + + (failed.isEmpty() ? "" : " (" + verdict.get("reason") + ")")); + return record; + } + + /** + * Caller-owned extraction schema, shared with the fromText samples: the same information-negotiation contracts work + * for both generation paths because the validate*AndDataFilling interfaces and the rendered wire format are + * identical. + */ + static Map extractionSchema(String api) { + return switch (api) { + case "propose" -> InformationNegotiationSchemas.propose(); + case "accept" -> InformationNegotiationSchemas.accept(); + case "reject" -> InformationNegotiationSchemas.reject(); + default -> throw new IllegalArgumentException("Unknown api: " + api); + }; + } + + /** + * Asserts that semantic extraction returns exactly the negotiated business field names, and that accept/reject + * payloads transport the input item text (supplied value / reason). + */ + private static void checkExtractedFields( + List> checks, + String api, + Map extractedJson, + Map itemsInput, + Map input) { + List> extractedItems = asMapList(extractedJson.get("items")); + 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 ("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)))); + } + } + } + + /** 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) { + return false; + } + String extractedText = normalize(String.valueOf(extracted)); + String inputText = normalize(input); + return !extractedText.isEmpty() && (extractedText.contains(inputText) || inputText.contains(extractedText)); + } + + private static Map errorJson(RuntimeException error) { + Map failure = new LinkedHashMap<>(); + failure.put("type", error.getClass().getSimpleName()); + if (error instanceof A2ATError a2atError) { + failure.put("code", a2atError.getCode()); + } + failure.put("message", String.valueOf(error.getMessage())); + return failure; + } + + private static String resolveTemplate(String api, Map input) { + if (input.containsKey("template")) { + return String.valueOf(input.get("template")); + } + return "propose".equals(api) + ? StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI + : StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI; + } + + private static void check(List> checks, String name, boolean pass) { + Map check = new LinkedHashMap<>(); + check.put("name", name); + check.put("pass", pass); + checks.add(check); + } + + private static void attachLlmCalls(Map step) { + List> calls = EvalLlmCaptureClient.drain(); + if (!calls.isEmpty()) { + step.put("llm_calls", calls); + } + } + + // -- report -- + + private static Map metrics(List> cases) { + int pass = 0; + for (Map record : cases) { + Map verdict = (Map) record.get("verdict"); + if (Boolean.TRUE.equals(verdict.get("pass"))) { + pass++; + } + } + Map metrics = new LinkedHashMap<>(); + metrics.put("cases", cases.size()); + metrics.put("passed", pass); + metrics.put("pct", cases.isEmpty() ? 0 : Math.round(pass * 1000.0 / cases.size()) / 10.0); + return metrics; + } + + private static void writeReport(Map report, Path outPath) { + try { + if (outPath.toAbsolutePath().getParent() != null) { + Files.createDirectories(outPath.toAbsolutePath().getParent()); + } + MAPPER.writerWithDefaultPrettyPrinter().writeValue(outPath.toFile(), report); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to write the evaluation report: " + outPath, exception); + } + } + + // -- suite loading and small helpers -- + + private static Map loadSuite() { + try (InputStream in = NegotiationFromDataApiEvalApp.class.getResourceAsStream("/" + SUITE_RESOURCE)) { + if (in == null) { + throw new IllegalStateException("Eval suite resource not found: " + SUITE_RESOURCE); + } + return MAPPER.readValue(in, new TypeReference>() {}); + } catch (IOException exception) { + throw new UncheckedIOException("Failed to load the eval suite: " + SUITE_RESOURCE, exception); + } + } + + private static Map step(String name, String role) { + Map step = new LinkedHashMap<>(); + step.put("step", name); + step.put("role", role); + return step; + } + + private static void api(Map step, Map call) { + step.put("api_calls", List.of(call)); + } + + /** Negotiation context as report JSON, including the performative of the message it travels with. */ + 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 apiCall(String method, Map input) { + Map call = new LinkedHashMap<>(); + call.put("method", method); + call.put("input", input); + return call; + } + + @SuppressWarnings("unchecked") + private static List> steps(Map record) { + return (List>) record.get("steps"); + } + + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("\\s+", ""); + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object value) { + return value instanceof Map map ? (Map) map : Map.of(); + } + + @SuppressWarnings("unchecked") + private static List> asMapList(Object value) { + return value instanceof List list ? (List>) list : List.of(); + } + + private static List stringList(Object value) { + List values = new ArrayList<>(); + if (value instanceof List list) { + for (Object item : list) { + values.add(String.valueOf(item)); + } + } + return values; + } + + private static void emit(String line) { + sink.accept(line); + } +} 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..c63f8a55 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/DiagnosisService.java @@ -0,0 +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. + * + *

The text layout comes from the {@code diagnosis} templates of the scenario configuration ({@code result_line}, + * {@code detail_line} with a {@code {params}} placeholder, {@code advice_line}); the parameter values themselves are + * taken from what {@code validateTaskPromptAndDataFilling} extracted, so the result adapts to any Task-T input. In a + * real deployment this service would call the EMS/NMS north-bound API instead of rendering text. + * + * @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 and the scenario templates. + * + * @param params validated parameters from {@code validateTaskPromptAndDataFilling} + * @return diagnosis result text + */ + public static String diagnose(FilledParamData params) { + Map templates = ScenarioData.diagnosisTemplates(); + StringBuilder detail = new StringBuilder(); + if (params != null && params.data() != null) { + for (Map.Entry entry : params.data().entrySet()) { + if (isContextParam(entry.getKey())) { + continue; + } + Object value = entry.getValue(); + if (value != null && (!(value instanceof String s) || !s.isBlank())) { + detail.append(entry.getKey()).append("=").append(value).append(";"); + } + } + } + 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 new file mode 100644 index 00000000..c09f1dff --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/server/NegotiationAgentExecutor.java @@ -0,0 +1,285 @@ +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.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.server.A2ATServer; +import org.a2aproject.sdk.server.agentexecution.AgentExecutor; +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#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 + * 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.validateTaskPromptAndDataFilling( + taskPrompt, ScenarioData.taskSchema(), 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 + 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, + relationship, + 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, + NegotiationPerformative.ACCEPT), + 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), + 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(missingItem(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(missingItem(slotName)); + } + } + return missing; + } + + /** + * Builds one missing-item entry from the phrasing template in the scenario configuration + * ({@code missing_item_hint}, {@code {slot}} and {@code {description}} placeholders), falling back to the bare slot + * name when no template is configured. + */ + private static NegotiationItem missingItem(String slotName) { + String template = ScenarioData.negotiationPhrasing().getOrDefault("missing_item_hint", "{slot}"); + String description = slotDescription(slotName); + String hint = + template.replace("{slot}", slotName).replace("{description}", description == null ? "" : description); + return new NegotiationItem(slotName, hint); + } + + /** Looks up the description of one slot in the scenario Task-T schema. */ + private static String slotDescription(String slotName) { + Object properties = ScenarioData.taskSchema().get("properties"); + if (properties instanceof Map 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<>(); + 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()); + } + + private static Message buildReplyMessage( + RequestContext ctx, MetadataContent content, Map contextMap, String extensionUri) { + Map replyMetadata = + NegotiationMessage.buildMetadata(extensionUri, content.promptText(), 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..43c84d20 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/DemoConstants.java @@ -0,0 +1,39 @@ +package net.openan.a2at.sample.negotiation.shared; + +import net.openan.a2at.sdk.core.model.StandardTemplates; + +/** + * 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 String TASK_TEMPLATE = StandardTemplates.PRIVATE_LINE_COMPLAINT_URI; + + /** Negotiation-T information propose template (request missing information). */ + public static final String NEGOTIATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI; + + /** Negotiation-T information accept-reject template (accept after params filled). */ + public static final String NEGOTIATION_ACCEPT = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI; + + /** Metadata key carrying the JSON-serialised negotiation context map. */ + public static final String NEGOTIATION_CONTEXT_KEY = "negotiation_context"; + + /** 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..99cbaa27 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromDataStrategy.java @@ -0,0 +1,52 @@ +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.NegotiationContext; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.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, + String templateUri) { + return server.generateNegotiationProposePromptFromData( + new NegotiationProposeData(ctx, new InformationProposeContent(missingItems, relationship)), + templateUri); + } + + @Override + public MetadataContent generateAccept( + A2ATClient facade, NegotiationContext ctx, List filledItems, String templateUri) { + return facade.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData(ctx, new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems)), + templateUri); + } + + @Override + public MetadataContent generateAcceptServer( + A2ATServer server, NegotiationContext ctx, List filledItems, String templateUri) { + return server.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData(ctx, new InformationEndingContent(NegotiationConclusion.ACCEPT, filledItems)), + templateUri); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java new file mode 100644 index 00000000..3f575dd8 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/FromTextStrategy.java @@ -0,0 +1,77 @@ +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.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. + * 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 + */ +public final class FromTextStrategy implements NegotiationStrategy { + + @Override + public MetadataContent generatePropose( + A2ATServer server, + NegotiationContext ctx, + List missingItems, + String relationship, + String templateUri) { + String text = itemsToProposeText(missingItems, relationship); + return server.generateNegotiationProposePromptFromText(text, ctx, templateUri); + } + + @Override + public MetadataContent generateAccept( + A2ATClient facade, NegotiationContext ctx, List filledItems, String templateUri) { + String text = itemsToAcceptText(filledItems); + return facade.generateNegotiationAcceptPromptFromText(text, ctx, templateUri); + } + + @Override + public MetadataContent generateAcceptServer( + A2ATServer server, NegotiationContext ctx, List filledItems, String templateUri) { + String text = itemsToAcceptText(filledItems); + return server.generateNegotiationAcceptPromptFromText(text, ctx, templateUri); + } + + private static String itemsToProposeText(List items, String relationship) { + StringBuilder sb = + new StringBuilder(ScenarioData.negotiationPhrasing().getOrDefault("from_text_propose_prefix", "")); + appendNumberedItems(sb, items); + if (relationship != null && !relationship.isBlank()) { + sb.append(relationship); + } + return sb.toString(); + } + + private static String itemsToAcceptText(List items) { + 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()); + if (item.value() != null && !item.value().isBlank()) { + sb.append(":").append(item.value()); + } + sb.append(";"); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/InformationNegotiationSchemas.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/InformationNegotiationSchemas.java new file mode 100644 index 00000000..a7b1842c --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/InformationNegotiationSchemas.java @@ -0,0 +1,110 @@ +package net.openan.a2at.sample.negotiation.shared; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 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. + * + *

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 { + + 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( + "items", + itemArraySchema( + itemSchema( + "requirement", + "Meaning, format, constraint, or example requested for the information item"), + "Requested information items and their requirements")); + properties.put( + "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( + "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( + "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"); + 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/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..aa9582a4 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationMessage.java @@ -0,0 +1,80 @@ +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, String templateUri, Map contextMap) { + Map metadata = new LinkedHashMap<>(); + metadata.put(extensionUri, promptText); + metadata.put(DemoConstants.TEMPLATE_URI_KEY, templateUri); + 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/NegotiationStrategy.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java new file mode 100644 index 00000000..08844146 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/NegotiationStrategy.java @@ -0,0 +1,63 @@ +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.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 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. + * + * @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, + String 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, String 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, String 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..64eb8fcd --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/negotiation/shared/ScenarioData.java @@ -0,0 +1,70 @@ +package net.openan.a2at.sample.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.io.UncheckedIOException; +import java.util.Map; + +/** + * 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 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 + */ +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 validateTaskPromptAndDataFilling). */ + @SuppressWarnings("unchecked") + public static Map taskSchema() { + return (Map) scenarioMap().get("task_schema"); + } + + /** 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"); + } + + /** 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) 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"); + } + + @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/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..7c551f90 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/client/NegotiationClientMain.java @@ -0,0 +1,92 @@ +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.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.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +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, NegotiationPerformative.PROPOSE); + 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; + NegotiationContext endingContext = NegotiationMetadataReader.readContext(endingMetadata); + Map result = decision == NegotiationDecision.ACCEPT + ? client.validateAcceptPromptAndDataFilling( + prompt, + endingContext, + InformationNegotiationSchemas.accept(), + NegotiationSampleFlow.ENDING_TEMPLATE_URI) + .data() + : client.validateRejectPromptAndDataFilling( + prompt, + endingContext, + 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/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..72d3b9d1 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCase.java @@ -0,0 +1,16 @@ +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..6656eb09 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationCaseLoader.java @@ -0,0 +1,30 @@ +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..38ad5dac --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationAbnormalEvaluationMain.java @@ -0,0 +1,555 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.evaluation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationSampleFlow; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.LLMRuntimeError; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestrator; +import net.openan.a2at.sdk.negotiation.generation.NegotiationGenerationOrchestratorBuilder; +import net.openan.a2at.sdk.negotiation.resources.NegotiationReference; +import net.openan.a2at.sdk.negotiation.resources.NegotiationTemplateLoader; +import net.openan.a2at.sdk.server.A2ATServer; + +/** Runs deterministic abnormal-input checks against all six Negotiation-T facade APIs and core pipelines. */ +public final class NegotiationAbnormalEvaluationMain { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + private static final DateTimeFormatter FILE_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + private static final List NEGOTIATION_ERROR_CODES = List.of( + "template.not_found", + "negotiation.semantic_rejected", + "negotiation.field_missing", + "negotiation.invalid_input", + "negotiation.rule_violation", + "llm.invocation_failed", + "llm.response_invalid"); + + private NegotiationAbnormalEvaluationMain() {} + + public static void main(String[] args) throws IOException { + Path envPath = args.length > 0 + ? Path.of(args[0]) + : Path.of( + "a2a-t-sample", + "src", + "main", + "resources", + "sample", + "private-line-complaint-negotiation", + "qwen.env"); + String suffix = LocalDateTime.now(ZoneId.systemDefault()).format(FILE_TIMESTAMP); + Path reportPath = args.length > 1 + ? Path.of(args[1]) + : Path.of("a2a-t-sample", "target", "negotiation-abnormal-report-" + suffix + ".json"); + Path processLogPath = args.length > 2 + ? Path.of(args[2]) + : Path.of("a2a-t-sample", "target", "negotiation-abnormal-process-log-" + suffix + ".jsonl"); + + A2ATClient client = new A2ATClient(envPath); + A2ATServer server = new A2ATServer(envPath); + String runId = UUID.randomUUID().toString(); + List> results = new ArrayList<>(); + try (NegotiationEvaluationProcessLogger logger = + new NegotiationEvaluationProcessLogger(OBJECT_MAPPER, processLogPath)) { + logger.write(event( + "run_started", + runId, + null, + Map.of( + "env_path", + envPath.toAbsolutePath().toString(), + "case_count", + NegotiationAbnormalEvaluationCaseLoader.load().size() + + scriptedCases().size()), + null, + null)); + for (NegotiationAbnormalEvaluationCase testCase : NegotiationAbnormalEvaluationCaseLoader.load()) { + results.add(runCase(client, server, testCase, runId, logger)); + } + for (ScriptedAbnormalCase testCase : scriptedCases()) { + results.add(runScriptedCase(testCase, runId, logger)); + } + } + + long passed = results.stream() + .filter(result -> Boolean.TRUE.equals(result.get("passed"))) + .count(); + Map report = new LinkedHashMap<>(); + report.put("generated_at", Instant.now().toString()); + report.put("run_id", runId); + report.put("git_revision", gitRevision()); + report.put("total", results.size()); + report.put("passed", passed); + report.put("failed", results.size() - passed); + report.put("success_rate", results.isEmpty() ? 1.0 : (double) passed / results.size()); + report.put("definition", "异常用例在预期 SDK 异常发生且错误类型/错误码匹配时判定通过;门面用例使用边界输入,脚本化用例注入固定 LLM 响应,不访问外部 LLM。"); + report.put("error_code_coverage", errorCodeCoverage(results)); + report.put("process_log", processLogPath.toAbsolutePath().toString()); + report.put("cases", results); + Path parent = reportPath.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + OBJECT_MAPPER.writeValue(reportPath.toFile(), report); + System.out.printf( + "Abnormal negotiation evaluation complete: %d/%d passed; report=%s; process-log=%s%n", + passed, results.size(), reportPath.toAbsolutePath(), processLogPath.toAbsolutePath()); + } + + private static Map errorCodeCoverage(List> results) { + Map counts = new LinkedHashMap<>(); + NEGOTIATION_ERROR_CODES.forEach(code -> counts.put(code, 0)); + Set observed = new LinkedHashSet<>(); + for (Map result : results) { + Object error = result.get("actual_error"); + if (error instanceof Map errorMap && errorMap.get("code") instanceof String code) { + observed.add(code); + if (counts.containsKey(code)) { + counts.computeIfPresent(code, (ignored, count) -> count + 1); + } + } + } + List missing = NEGOTIATION_ERROR_CODES.stream() + .filter(code -> counts.get(code) == 0) + .toList(); + List unexpected = + observed.stream().filter(code -> !counts.containsKey(code)).toList(); + Map coverage = new LinkedHashMap<>(); + coverage.put("expected_codes", NEGOTIATION_ERROR_CODES); + coverage.put("observed_counts", counts); + coverage.put("missing_codes", missing); + coverage.put("unexpected_codes", unexpected); + coverage.put("all_expected_codes_covered", missing.isEmpty()); + return coverage; + } + + private static Map runCase( + A2ATClient client, + A2ATServer server, + NegotiationAbnormalEvaluationCase testCase, + String runId, + NegotiationEvaluationProcessLogger logger) + throws IOException { + long startedAt = System.nanoTime(); + NegotiationPerformative performative = NegotiationPerformative.valueOf(testCase.performative()); + NegotiationContext context = contextFor(testCase.contextMode(), performative); + String templateUri = templateFor(testCase.template()); + Map request = new LinkedHashMap<>(); + if (testCase.api().startsWith("generate_")) { + request.put("text", testCase.input()); + } else { + request.put("prompt", testCase.input()); + request.put("schema", schemaFor(testCase.api(), testCase.schemaMode())); + } + request.put("template_uri", templateUri); + request.put("context", context == null ? null : contextPayload(context)); + Map result = new LinkedHashMap<>(); + result.put("id", testCase.id()); + result.put("description", testCase.description()); + result.put("api", apiMethod(testCase.api())); + result.put("expected_exception", testCase.expectedException()); + result.put("expected_code", testCase.expectedCode()); + result.put("request", request); + try { + Object response = invoke(client, server, testCase, context, templateUri); + result.put("outcome", "unexpected_success"); + result.put("response", responseSummary(response)); + result.put("passed", false); + logger.write(event( + "abnormal_case", runId, testCase.id(), request, Map.of("outcome", "unexpected_success"), result)); + } catch (RuntimeException exception) { + Map actualError = errorDetails(exception); + boolean classMatched = testCase.expectedException().equals(actualError.get("class")); + boolean codeMatched = + testCase.expectedCode() == null || testCase.expectedCode().equals(actualError.get("code")); + result.put("actual_error", actualError); + result.put("passed", classMatched && codeMatched); + result.put("outcome", classMatched && codeMatched ? "expected_failure" : "unexpected_failure"); + logger.write(event("abnormal_case", runId, testCase.id(), request, null, result)); + } + result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); + return result; + } + + private static List scriptedCases() { + return List.of( + new ScriptedAbnormalCase( + "G-PROPOSE-LLM-MALFORMED", + "generate_propose", + "请补充接入端口名称和投诉分类。", + "not-json", + false, + "NegotiationGenerationException", + "llm.response_invalid"), + new ScriptedAbnormalCase( + "G-ACCEPT-LLM-INFRA", + "generate_accept", + "接受协商,端口为 PE1-GZ-ETH-0/0/0.200,投诉分类为专线中断。", + "throw", + false, + "NegotiationGenerationException", + "llm.invocation_failed"), + new ScriptedAbnormalCase( + "G-REJECT-SLOT-MISSING", + "generate_reject", + "拒绝协商,原因是资源系统暂时无法查询。", + "{\"conclusion\":\"Reject\",\"items\":null}", + false, + "NegotiationGenerationException", + "negotiation.field_missing"), + new ScriptedAbnormalCase( + "V-PROPOSE-SEMANTIC-REJECTED", + "validate_propose", + "## 信息协商\n请根据<所需信息项>补充相关内容。", + semanticRejectedPayload(), + false, + "NegotiationParamExtractionException", + "negotiation.semantic_rejected"), + new ScriptedAbnormalCase( + "V-ACCEPT-LLM-INFRA", + "validate_accept", + "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n1. 接入端口名称:PE1-GZ-ETH-0/0/0.200\n2. 投诉分类:专线中断", + "throw", + false, + "NegotiationParamExtractionException", + "llm.invocation_failed"), + new ScriptedAbnormalCase( + "V-REJECT-SEMANTIC-REJECTED", + "validate_reject", + "## 信息协商结果\nReject\n\n## 信息协商结果内容\n1. 接入端口名称:无法提供,原因:资源系统暂时无法查询\n2. 投诉分类:无法提供,原因:资源系统暂时无法查询", + semanticRejectedPayload(), + false, + "NegotiationParamExtractionException", + "negotiation.semantic_rejected"), + new ScriptedAbnormalCase( + "G-PROPOSE-TEMPLATE-NOT-FOUND", + "generate_propose", + "请补充接入端口名称和投诉分类。", + "unused", + true, + "NegotiationGenerationException", + "template.not_found"), + new ScriptedAbnormalCase( + "V-PROPOSE-TEMPLATE-NOT-FOUND", + "validate_propose", + "## 信息协商\n请根据<所需信息项>补充相关内容。", + "unused", + true, + "NegotiationParamExtractionException", + "template.not_found")); + } + + private static String semanticRejectedPayload() { + return "{\"semantic_verdict\":false,\"negotiation_type\":\"information\"," + + "\"errors\":[{\"slot_name\":\"section.info_result_content\"," + + "\"code\":\"negotiation.missing_result_content\"," + + "\"facts\":{\"section_label\":\"信息协商结果内容\"}}]," + + "\"params\":{}}"; + } + + private static Map runScriptedCase( + ScriptedAbnormalCase testCase, String runId, NegotiationEvaluationProcessLogger logger) throws IOException { + long startedAt = System.nanoTime(); + NegotiationPerformative performative = testCase.api().contains("accept") + ? NegotiationPerformative.ACCEPT + : testCase.api().contains("reject") ? NegotiationPerformative.REJECT : NegotiationPerformative.PROPOSE; + NegotiationContext context = new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); + String template = testCase.api().contains("propose") + ? NegotiationSampleFlow.PROPOSE_TEMPLATE_URI + : NegotiationSampleFlow.ENDING_TEMPLATE_URI; + ScriptedLlmClient llm = new ScriptedLlmClient(testCase.payload()); + NegotiationGenerationOrchestratorBuilder builder = NegotiationGenerationOrchestratorBuilder.builder() + .language("zh-CN") + .llmClient(llm) + .maxAttempts(1); + if (testCase.missingTemplate()) { + builder.templateLoader(new MissingTemplateLoader()); + } + NegotiationGenerationOrchestrator orchestrator = builder.build(); + Map request = new LinkedHashMap<>(); + if (testCase.api().startsWith("generate_")) { + request.put("text", testCase.input()); + } else { + request.put("prompt", testCase.input()); + request.put("schema", schemaFor(testCase.api(), null)); + } + request.put("context", contextPayload(context)); + request.put("template_uri", template); + Map result = new LinkedHashMap<>(); + result.put("id", testCase.id()); + result.put("api", scriptedApiMethod(testCase.api())); + result.put("request", request); + result.put("expected_exception", testCase.expectedException()); + result.put("expected_code", testCase.expectedCode()); + try { + Object response = invoke(orchestrator, testCase, context, parseTemplateUri(template)); + result.put("response", responseSummary(response)); + result.put("outcome", "unexpected_success"); + result.put("passed", false); + logger.write( + event("abnormal_scripted_case", runId, testCase.id(), request, responseSummary(response), result)); + } catch (RuntimeException exception) { + Map actualError = errorDetails(exception); + boolean matched = testCase.expectedException().equals(actualError.get("class")) + && testCase.expectedCode().equals(actualError.get("code")); + result.put("actual_error", actualError); + result.put("outcome", matched ? "expected_failure" : "unexpected_failure"); + result.put("passed", matched); + logger.write(event("abnormal_scripted_case", runId, testCase.id(), request, null, result)); + } + result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); + return result; + } + + private static Object invoke( + NegotiationGenerationOrchestrator orchestrator, + ScriptedAbnormalCase testCase, + NegotiationContext context, + TemplateUri template) { + return switch (testCase.api()) { + case "generate_propose" -> orchestrator.generateProposeFromText(testCase.input(), context, template); + case "generate_accept" -> orchestrator.generateAcceptFromText(testCase.input(), context, template); + case "generate_reject" -> orchestrator.generateRejectFromText(testCase.input(), context, template); + case "validate_propose" -> orchestrator.validateProposePromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.propose(), template); + case "validate_accept" -> orchestrator.validateAcceptPromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.accept(), template); + case "validate_reject" -> orchestrator.validateRejectPromptAndDataFilling( + testCase.input(), context, InformationNegotiationSchemas.reject(), template); + default -> throw new IllegalArgumentException("Unknown scripted abnormal API: " + testCase.api()); + }; + } + + private static Object invoke( + A2ATClient client, + A2ATServer server, + NegotiationAbnormalEvaluationCase testCase, + NegotiationContext context, + String templateUri) { + return switch (testCase.api()) { + case "generate_propose" -> client.generateNegotiationProposePromptFromText( + testCase.input(), context, templateUri); + case "validate_propose" -> server.validateProposePromptAndDataFilling( + testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); + case "generate_accept" -> server.generateNegotiationAcceptPromptFromText( + testCase.input(), context, templateUri); + case "validate_accept" -> client.validateAcceptPromptAndDataFilling( + testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); + case "generate_reject" -> server.generateNegotiationRejectPromptFromText( + testCase.input(), context, templateUri); + case "validate_reject" -> client.validateRejectPromptAndDataFilling( + testCase.input(), context, schemaFor(testCase.api(), testCase.schemaMode()), templateUri); + default -> throw new IllegalArgumentException("Unknown abnormal evaluation API: " + testCase.api()); + }; + } + + private static Map schemaFor(String api, String mode) { + if ("null".equalsIgnoreCase(mode)) { + return null; + } + return switch (api) { + case "validate_propose" -> InformationNegotiationSchemas.propose(); + case "validate_accept" -> InformationNegotiationSchemas.accept(); + case "validate_reject" -> InformationNegotiationSchemas.reject(); + default -> Map.of(); + }; + } + + private static NegotiationContext contextFor(String mode, NegotiationPerformative performative) { + if ("null".equalsIgnoreCase(mode)) { + return null; + } + if ("invalid_id".equalsIgnoreCase(mode)) { + return new NegotiationContext("not-a-uuid", 1, 3, performative); + } + if ("exhausted".equalsIgnoreCase(mode)) { + return new NegotiationContext(UUID.randomUUID().toString(), 4, 3, performative); + } + return new NegotiationContext(UUID.randomUUID().toString(), 1, 3, performative); + } + + private static String templateFor(String template) { + return switch (template == null ? "null" : template.toLowerCase(java.util.Locale.ROOT)) { + case "null" -> null; + case "propose" -> NegotiationSampleFlow.PROPOSE_TEMPLATE_URI; + case "ending" -> NegotiationSampleFlow.ENDING_TEMPLATE_URI; + case "unknown_propose" -> "Negotiation-T/information-negotiation/propose/v999"; + case "unknown_ending" -> "Negotiation-T/information-negotiation/accept-reject/v999"; + default -> throw new IllegalArgumentException("Unknown abnormal evaluation template: " + template); + }; + } + + /** Parses a template URI string for the typed {@code NegotiationGenerationOrchestrator} seam. */ + private static TemplateUri parseTemplateUri(String templateUri) { + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); + } + + private static String apiMethod(String api) { + return switch (api) { + case "generate_propose" -> "A2ATClient.generateNegotiationProposePromptFromText"; + case "validate_propose" -> "A2ATServer.validateProposePromptAndDataFilling"; + case "generate_accept" -> "A2ATServer.generateNegotiationAcceptPromptFromText"; + case "validate_accept" -> "A2ATClient.validateAcceptPromptAndDataFilling"; + case "generate_reject" -> "A2ATServer.generateNegotiationRejectPromptFromText"; + case "validate_reject" -> "A2ATClient.validateRejectPromptAndDataFilling"; + default -> api; + }; + } + + private static Map responseSummary(Object response) { + Map summary = new LinkedHashMap<>(); + if (response instanceof MetadataContent metadata) { + summary.put("type", "MetadataContent"); + summary.put("prompt", metadata.promptText()); + summary.put("template_uri", metadata.templateUri()); + } else if (response instanceof FilledParamData filled) { + summary.put("type", "FilledParamData"); + summary.put("data", filled.data()); + } else { + summary.put("type", response == null ? "null" : response.getClass().getName()); + } + return summary; + } + + private static Map event( + String stage, + String runId, + String caseId, + Map request, + Map response, + Map result) { + Map event = new LinkedHashMap<>(); + event.put("timestamp", Instant.now().toString()); + event.put("run_id", runId); + event.put("stage", stage); + if (result != null && result.get("api") != null) { + event.put("api", result.get("api")); + } + if (caseId != null) { + event.put("case_id", caseId); + } + event.put("request", request); + if (response != null) { + event.put("response", response); + } + if (result != null) { + event.put("result", result); + } + return event; + } + + private static Map errorDetails(RuntimeException exception) { + Map error = new LinkedHashMap<>(); + error.put("class", exception.getClass().getSimpleName()); + error.put("message", exception.getMessage()); + if (exception instanceof A2ATError a2atError) { + error.put("code", a2atError.getCode()); + } + return error; + } + + private static Map contextPayload(NegotiationContext context) { + return Map.of( + "id", context.id(), + "round", context.round(), + "maxRounds", context.maxRounds(), + "performative", context.performative().name()); + } + + private static String scriptedApiMethod(String api) { + return switch (api) { + case "generate_propose" -> "NegotiationGenerationOrchestrator.generateProposeFromText"; + case "generate_accept" -> "NegotiationGenerationOrchestrator.generateAcceptFromText"; + case "generate_reject" -> "NegotiationGenerationOrchestrator.generateRejectFromText"; + case "validate_propose" -> "NegotiationGenerationOrchestrator.validateProposePromptAndDataFilling"; + case "validate_accept" -> "NegotiationGenerationOrchestrator.validateAcceptPromptAndDataFilling"; + case "validate_reject" -> "NegotiationGenerationOrchestrator.validateRejectPromptAndDataFilling"; + default -> api; + }; + } + + private record ScriptedAbnormalCase( + String id, + String api, + String input, + String payload, + boolean missingTemplate, + String expectedException, + String expectedCode) {} + + private static final class ScriptedLlmClient implements LLMClient { + + private final String payload; + + private ScriptedLlmClient(String payload) { + this.payload = payload; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + if ("throw".equals(payload)) { + throw new LLMRuntimeError("scripted LLM infrastructure failure"); + } + return new LLMResponse( + payload, "sample-scripted-model", Map.of("prompt_tokens", 1, "completion_tokens", 1), Map.of()); + } + } + + private static final class MissingTemplateLoader implements NegotiationTemplateLoader { + + @Override + public PromptTemplate load(NegotiationReference reference) { + throw new ResourceNotFoundException("Negotiation template does not exist.", reference.uri()); + } + } + + private static String gitRevision() { + try { + Process process = new ProcessBuilder("git", "rev-parse", "HEAD") + .redirectErrorStream(true) + .start(); + String revision; + try (var input = process.getInputStream()) { + revision = new String(input.readAllBytes(), StandardCharsets.UTF_8).trim(); + } + return process.waitFor() == 0 && !revision.isBlank() ? revision : "unknown"; + } catch (IOException exception) { + return "unknown"; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return "unknown"; + } + } +} 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..2b514b59 --- /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, Boolean modelDependent, List expectedCodes) { + + public NegotiationBusinessAbnormalEvaluationCase { + modelDependent = Boolean.TRUE.equals(modelDependent); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/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..6f8f9dfa --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationCaseLoader.java @@ -0,0 +1,28 @@ +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..b6435ca4 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationBusinessAbnormalEvaluationMain.java @@ -0,0 +1,312 @@ +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.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,结果可能受模型输出影响。" + + "全部用例的期望失败均依赖模型判定(生成侧依赖模型拒绝从模糊输入抽取内容,校验侧依赖语义门模型拒绝结构违规):" + + "模型放行并成功返回(model_dependent_success)同样判定通过,仅意外错误码判定失败。" + + "确定性的 SDK 异常契约验证由 negotiation-abnormal-evaluation 承担。"); + 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)); + if (testCase.modelDependent()) { + result.put("outcome", "model_dependent_success"); + result.put("passed", true); + } else { + result.put("outcome", "unexpected_success"); + result.put("passed", false); + } + logger.write(event(runId, testCase, request, responseSummary(response), result, startedAt)); + } catch (RuntimeException exception) { + Map error = errorDetails(exception); + 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 + : NegotiationSampleFlow.ENDING_TEMPLATE_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/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..b4ac3fd1 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCase.java @@ -0,0 +1,44 @@ +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 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/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..2e1e7cf1 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationCaseLoader.java @@ -0,0 +1,132 @@ +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.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 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"; + /** + * 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(); + + 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); + } + } + + /** + * 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(); + } + + /** Loads the checked-in 20-case smoke set. */ + 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..66c803e0 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationFlowCase.java @@ -0,0 +1,20 @@ +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(); + } +} 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..d4ad0f32 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLogger.java @@ -0,0 +1,43 @@ +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; +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.copy().disable(SerializationFeature.INDENT_OUTPUT); + 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..317f3056 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationQwenEvaluationMain.java @@ -0,0 +1,786 @@ +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.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.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.A2ATParamExtractionError; +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.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 the full negotiation protocol chain (mirroring NegotiationEvalApp steps 3-7): the server generates + * the propose; the client validates the inbound propose and discovers the fields it is asked to supplement; the client + * generates the accept/reject from that discovery (fromData channel fills exactly the discovered fields, fromText + * channel voices the ending case's natural language); the server validates the ending message. The fifth argument + * selects the generation channel: {@code fromText} (default) or {@code fromData}. + */ +public final class NegotiationQwenEvaluationMain { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + + private NegotiationQwenEvaluationMain() {} + + 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); + 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); + + 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 (NegotiationEvaluationFlowCase testCase : testCases) { + results.add(runCase(client, server, testCase, channel, runId, processLogger)); + } + } + + long passed = results.stream() + .filter(result -> Boolean.TRUE.equals(result.get("passed"))) + .count(); + long proposeSucceeded = results.stream() + .filter(result -> Boolean.TRUE.equals(result.get("propose_succeeded"))) + .count(); + 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); + 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("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("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()); + 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, + 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", + 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_requested_fields", null); + result.put("ending_input", null); + 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); + 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(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( + 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); + + NegotiationPerformative endingPerformative = "accept".equals(testCase.decision()) + ? NegotiationPerformative.ACCEPT + : NegotiationPerformative.REJECT; + NegotiationContext responseContext = + NegotiationSampleFlow.contextFrom(proposeFilled.data(), endingPerformative); + MetadataContent ending = generateEndingWithLog( + client, testCase, requestedFields, responseContext, channel, runId, processLogger, apiTrace); + result.put("ending_generation_succeeded", true); + result.put("generated_ending_prompt", ending.promptText()); + boolean endingGoldenExact = + ending.promptText().equals(testCase.endingCase().completedPrompt()); + result.put("ending_golden_exact", endingGoldenExact); + FilledParamData endingFilled = validateEndingWithLog( + 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); + 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)); + } + result.put("elapsed_ms", (System.nanoTime() - startedAt) / 1_000_000); + return result; + } + + private static MetadataContent generateProposeWithLog( + 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) + : Map.of("text", proposeCase.text(), "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + try { + 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", + channel, + context, + request, + null, + startedAt, + exception)); + throw exception; + } + } + + private static FilledParamData validateProposeWithLog( + 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 = client.validateProposePromptAndDataFilling( + prompt, context, schema, NegotiationSampleFlow.PROPOSE_TEMPLATE_URI); + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "validate_propose_and_fill", + "propose", + channel, + null, + Map.of( + "prompt", prompt, + "schema", schema, + "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI), + Map.of("filled_data", filled.data()), + startedAt, + null)); + return filled; + } catch (RuntimeException exception) { + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "validate_propose_and_fill", + "propose", + channel, + null, + Map.of( + "prompt", prompt, + "schema", schema, + "template_uri", NegotiationSampleFlow.PROPOSE_TEMPLATE_URI), + null, + startedAt, + exception)); + throw exception; + } + } + + /** + * Generates the client's accept/reject. The client fills exactly the fields it discovered from the inbound propose + * (step 2 output), taking each value/reason from the ending case. + */ + private static MetadataContent generateEndingWithLog( + 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); + // 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) + : Map.of("text", endingInputText, "template_uri", NegotiationSampleFlow.ENDING_TEMPLATE_URI); + try { + MetadataContent content = fromData + ? accept + ? client.generateNegotiationAcceptPromptFromData( + new NegotiationEndingData( + context, + new InformationEndingContent(NegotiationConclusion.ACCEPT, endingItems)), + NegotiationSampleFlow.ENDING_TEMPLATE_URI) + : client.generateNegotiationRejectPromptFromData( + new NegotiationEndingData( + context, + new InformationEndingContent(NegotiationConclusion.REJECT, endingItems)), + NegotiationSampleFlow.ENDING_TEMPLATE_URI) + : accept + ? client.generateNegotiationAcceptPromptFromText( + endingInputText, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI) + : client.generateNegotiationRejectPromptFromText( + endingInputText, context, NegotiationSampleFlow.ENDING_TEMPLATE_URI); + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "generate_" + testCase.decision(), + testCase.decision(), + channel, + context, + request, + Map.of( + "prompt", + content.promptText(), + "template_uri", + content.templateUri(), + "extension_uri", + content.extensionUri()), + startedAt, + null)); + return content; + } catch (RuntimeException exception) { + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "generate_" + testCase.decision(), + testCase.decision(), + channel, + context, + request, + null, + startedAt, + exception)); + throw exception; + } + } + + private static FilledParamData validateEndingWithLog( + A2ATServer server, + NegotiationEvaluationFlowCase testCase, + String prompt, + NegotiationContext context, + String channel, + String runId, + NegotiationEvaluationProcessLogger processLogger, + List> apiTrace) + throws IOException { + long startedAt = System.nanoTime(); + Map schema = "accept".equals(testCase.decision()) + ? InformationNegotiationSchemas.accept() + : InformationNegotiationSchemas.reject(); + try { + FilledParamData filled = "accept".equals(testCase.decision()) + ? server.validateAcceptPromptAndDataFilling( + prompt, context, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI) + : server.validateRejectPromptAndDataFilling( + prompt, context, schema, NegotiationSampleFlow.ENDING_TEMPLATE_URI); + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "validate_" + testCase.decision() + "_and_fill", + testCase.decision(), + channel, + null, + Map.of( + "prompt", + prompt, + "schema", + schema, + "template_uri", + NegotiationSampleFlow.ENDING_TEMPLATE_URI), + Map.of("filled_data", filled.data()), + startedAt, + null)); + return filled; + } catch (RuntimeException exception) { + writeStage( + processLogger, + apiTrace, + stageEvent( + runId, + testCase, + "validate_" + testCase.decision() + "_and_fill", + testCase.decision(), + channel, + null, + Map.of( + "prompt", + prompt, + "schema", + schema, + "template_uri", + NegotiationSampleFlow.ENDING_TEMPLATE_URI), + null, + startedAt, + exception)); + 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; + } + + /** 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, + String stage, + String phase, + String channel, + 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()); + int step = stageStep(stage); + event.put("step", step); + event.put("step_label", step + "/4"); + 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()); + 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 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, 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) { + 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) { + 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()); + 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; + } + + 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 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 List loadCases(String selector) { + return switch (selector.toLowerCase(java.util.Locale.ROOT)) { + case "", "full" -> NegotiationEvaluationCaseLoader.loadFlows(); + case "smoke" -> NegotiationEvaluationCaseLoader.loadSmokeFlows(); + default -> NegotiationEvaluationCaseLoader.loadSelectedFlows(parseCaseIds(selector)); + }; + } + + 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('.'); + String logFilename = (extensionStart < 0 ? filename : filename.substring(0, extensionStart)) + "-process.jsonl"; + Path parent = reportPath.getParent(); + 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")) + && 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..928a877d --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/server/NegotiationAgentExecutor.java @@ -0,0 +1,84 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.server; + +import java.util.List; +import java.util.Map; +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; +import net.openan.a2at.sample.private_line_complaint.negotiation.shared.NegotiationScenario; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.server.A2ATServer; +import org.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(); + Map metadata = message == null ? null : message.metadata(); + String proposePrompt = + NegotiationMetadataReader.readPrompt(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) + : 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..7b8f6ba8 --- /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/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..aed41c15 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationAgentCard.java @@ -0,0 +1,42 @@ +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..904d8f65 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationMetadataReader.java @@ -0,0 +1,94 @@ +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.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; + +/** Reads and validates Negotiation-T metadata received over A2A. */ +public final class NegotiationMetadataReader { + + private NegotiationMetadataReader() {} + + public static String readPrompt(Map metadata, String expectedTemplateUri) { + if (metadata == null) { + throw new IllegalArgumentException("Negotiation metadata is required"); + } + String templateUri = stringValue(metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + if (!expectedTemplateUri.equals(templateUri)) { + throw new IllegalArgumentException( + "Unexpected negotiation template URI: expected=" + expectedTemplateUri + ", 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; + } + + /** + * Reads the negotiation context carried in the {@code negotiationContext} metadata entry. + * + *

The nested map must carry the four fields {@code id}, {@code round}, {@code maxRounds}, and + * {@code performative}; the performative entry must hold a valid upper-case performative name. + * + * @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"); + } + NegotiationPerformative performative = readPerformative(contextMap.get("performative")); + return new NegotiationContext(idText, roundNumber.intValue(), maxRoundsNumber.intValue(), performative); + } + + /** + * Reads the {@code performative} entry of the nested context map. + * + *

A missing entry is malformed: every negotiation context carries the performative of its message. A present + * entry must hold one of the four upper-case performative names. + * + * @param value candidate wire value, may be {@code null} + * @return the matching performative + * @throws IllegalArgumentException when the entry is absent or is not a valid performative name + */ + private static NegotiationPerformative readPerformative(Object value) { + if (value == null) { + throw new IllegalArgumentException("Negotiation-T context metadata has no performative entry"); + } + return NegotiationPerformative.tryParse(String.valueOf(value)) + .orElseThrow(() -> new IllegalArgumentException( + "Negotiation-T context metadata has an unknown performative: " + value)); + } + + 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..ba62d191 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleEnvironment.java @@ -0,0 +1,58 @@ +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..9aad07dc --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationSampleFlow.java @@ -0,0 +1,83 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.shared; + +import java.util.Map; +import java.util.UUID; +import net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +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 String PROPOSE_TEMPLATE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI; + + public static final String ENDING_TEMPLATE_URI = StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI; + + private NegotiationSampleFlow() {} + + public static NegotiationFlowResult run( + A2ATClient client, A2ATServer server, NegotiationScenario scenario, NegotiationDecision decision) { + NegotiationContext requestContext = + new NegotiationContext(UUID.randomUUID().toString(), 1, 3, NegotiationPerformative.PROPOSE); + MetadataContent propose = client.generateNegotiationProposePromptFromText( + scenario.proposeText(), requestContext, PROPOSE_TEMPLATE_URI); + 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 = proposeContext; + MetadataContent ending = decision == NegotiationDecision.ACCEPT + ? server.generateNegotiationAcceptPromptFromText( + scenario.acceptText(), responseContext, ENDING_TEMPLATE_URI) + : server.generateNegotiationRejectPromptFromText( + scenario.rejectText(), responseContext, ENDING_TEMPLATE_URI); + Map endingMetadata = ending.buildMetadataContent(); + String endingPrompt = NegotiationMetadataReader.readPrompt(endingMetadata, ENDING_TEMPLATE_URI); + NegotiationContext endingContext = NegotiationMetadataReader.readContext(endingMetadata); + FilledParamData endingData = decision == NegotiationDecision.ACCEPT + ? client.validateAcceptPromptAndDataFilling( + endingPrompt, endingContext, InformationNegotiationSchemas.accept(), ENDING_TEMPLATE_URI) + : client.validateRejectPromptAndDataFilling( + endingPrompt, endingContext, InformationNegotiationSchemas.reject(), ENDING_TEMPLATE_URI); + return new NegotiationFlowResult(requestContext, propose, proposeData, ending, endingData, decision); + } + + /** + * Builds a context from the filled negotiation data. + * + *

The merged data carries the three session fields, so the performative of the message the context travels with + * is passed in explicitly. The performative of an outbound message is stamped by the generation API + * ({@code completeGeneration}), overriding whatever this context carries. + * + * @param data filled negotiation data holding the {@code id}, {@code round}, and {@code maxRounds} fields + * @param performative communicative intent of the message the returned context travels with + * @return the negotiation context + * @throws IllegalArgumentException when the data does not contain a valid context + */ + public static NegotiationContext contextFrom(Map data, NegotiationPerformative performative) { + 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(), performative); + } + + 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..d914cefd --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenario.java @@ -0,0 +1,4 @@ +package net.openan.a2at.sample.private_line_complaint.negotiation.shared; + +/** Natural-language inputs used by the private-line complaint negotiation sample. */ +public record NegotiationScenario(String scenario, String proposeText, String acceptText, String rejectText) {} 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..e671c93c --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/NegotiationScenarioLoader.java @@ -0,0 +1,42 @@ +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..45fb830e --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLLMClient.java @@ -0,0 +1,118 @@ +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":"当前账号没有资源系统查询权限"},{"name":"投诉分类", + "value":"当前账号没有资源系统查询权限"}]} + """; + + private static final String PROPOSE_PARAMS = + """ + {"items":[{"name":"接入端口名称","requirement":"物理端口或逻辑端口名称"}, + {"name":"投诉分类","requirement":"专线中断或专线质差"}],"relationship":null} + """; + + private static final String ACCEPT_PARAMS = + """ + {"items":[{"name":"接入端口名称","value":"P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)"}, + {"name":"投诉分类","value":"专线质差"}]} + """; + + private static final String REJECT_PARAMS = + """ + {"items":[{"name":"接入端口名称","reason":"当前账号没有资源系统查询权限"}, + {"name":"投诉分类","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..64b4a7c4 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/private_line_complaint/negotiation/shared/mock/NegotiationMockLlmInstaller.java @@ -0,0 +1,17 @@ +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/java/net/openan/a2at/sample/server/ServerSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/ServerSampleMain.java deleted file mode 100644 index 5f3374e2..00000000 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/ServerSampleMain.java +++ /dev/null @@ -1,77 +0,0 @@ -package net.openan.a2at.sample.server; - -import java.nio.file.Path; -import java.util.Map; -import java.util.function.Consumer; -import net.openan.a2at.sample.server.agentcard.ServerSampleAgentCardBuilder; -import net.openan.a2at.sample.server.flow.PromptComplianceChecker; -import net.openan.a2at.sample.server.http.EmbeddedA2AHttpServer; -import net.openan.a2at.sample.server.runtime.A2AJavaServerRuntime; -import net.openan.a2at.sample.server.runtime.DefaultSampleServerRuntime; -import net.openan.a2at.sample.server.runtime.SampleServerRuntime; -import net.openan.a2at.sample.server.runtime.SampleServerRuntimeFactory; -import net.openan.a2at.sample.server.runtime.ServerBind; -import net.openan.a2at.sample.server.runtime.ServerBootstrapResult; -import net.openan.a2at.sample.shared.registry.RegistryAgentCardMapper; -import org.a2aproject.sdk.server.requesthandlers.RequestHandler; - -/** - * Main entry orchestration for the server sample. - * - * @since 2026-05 - */ -public final class ServerSampleMain { - - private ServerSampleMain() { - } - - public static Path resolveEnvPath(String[] args) { - return args.length > 0 ? Path.of(args[0]) : DefaultSampleServerRuntime.resolveDefaultEnvPath(); - } - - public static ServerBootstrapResult runMain( - Path envPath, - SampleServerRuntimeFactory runtimeFactory, - Consumer logSink) { - SampleServerRuntime runtime = runtimeFactory.create(envPath); - ServerBind bind = runtime.resolveBind(); - emit(logSink, "[server] startup: host=" + bind.host() + " port=" + bind.port()); - Map agentCard = runtime.buildAgentCard(bind.host(), bind.port()); - PromptComplianceChecker promptChecker = runtime.buildPromptChecker(envPath); - Object app = runtime.buildApp(agentCard, promptChecker); - Map registrationPayload; - if (runtime instanceof A2AJavaServerRuntime a2aJavaServerRuntime - && a2aJavaServerRuntime.createRestApplication(bind.host(), bind.port()) instanceof org.a2aproject.sdk.spec.AgentCard agentCardModel) { - registrationPayload = RegistryAgentCardMapper.toRegistryRegistrationPayload(agentCardModel); - } else { - registrationPayload = ServerSampleAgentCardBuilder.buildRegistrationPayload(bind.host(), bind.port()); - } - Map registrationResult = runtime.registerAgentCard(registrationPayload, envPath); - if ("success".equals(registrationResult.get("status"))) { - emit(logSink, "[server] agent-card registration: success"); - } else { - emit(logSink, - "[server] agent-card registration failed, continuing startup: " - + registrationResult.getOrDefault("message", "unknown error")); - } - 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); - emit(logSink, "[server] http server started: http://" + bind.host() + ":" + bind.port()); - } - return new ServerBootstrapResult(bind.host(), bind.port(), app, serverHandle, registrationResult); - } - - 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(message); - } - } -} - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/agentcard/ServerSampleAgentCardBuilder.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/agentcard/ServerSampleAgentCardBuilder.java deleted file mode 100644 index 5f7a987c..00000000 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/agentcard/ServerSampleAgentCardBuilder.java +++ /dev/null @@ -1,57 +0,0 @@ -package net.openan.a2at.sample.server.agentcard; - -import java.util.List; -import java.util.Map; - -/** - * Builds sample-owned AgentCard payloads for the server demo. - * - * @since 2026-05 - */ -public final class ServerSampleAgentCardBuilder { - static final String TASK_T_EXTENSION_URI = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; - - static final String NOTIFICATION_T_EXTENSION_URI = - "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; - - private ServerSampleAgentCardBuilder() { - } - - public static Map buildAgentCard(String host, int port) { - return Map.of( - "name", "SPN Domain Agent", - "description", "SPN Domain 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", "Incident-Subscription", - "name", "Incident reporting", - "description", "Mock incident reporting sample skill", - "tags", List.of("incident", "reporting"))), - "capabilities", Map.of( - "streaming", true, - "pushNotifications", false, - "extensions", List.of( - Map.of( - "uri", TASK_T_EXTENSION_URI, - "description", "Extension of structured prompt Task-T requests."), - Map.of( - "uri", NOTIFICATION_T_EXTENSION_URI, - "description", "Extension of structured prompt Notification-T requests."))), - "supportedInterfaces", List.of(Map.of( - "protocolBinding", "HTTP+JSON", - "protocolVersion", "1.0", - "url", "http://" + host + ":" + port))); - } - - public static Map buildRegistrationPayload(String host, int port) { - return Map.of("agentCards", List.of(buildAgentCard(host, port))); - } -} - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/http/ServerSampleApplication.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/http/ServerSampleApplication.java deleted file mode 100644 index 6599f92e..00000000 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/http/ServerSampleApplication.java +++ /dev/null @@ -1,14 +0,0 @@ -package net.openan.a2at.sample.server.http; - -import java.util.Map; -import net.openan.a2at.sample.server.flow.PromptComplianceChecker; - -/** - * Minimal sample-owned application object for the publishable server sample. - * - * @since 2026-05 - */ -public record ServerSampleApplication(Map agentCard, PromptComplianceChecker promptChecker) { -} - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/ServerBind.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/ServerBind.java deleted file mode 100644 index fb419eac..00000000 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/ServerBind.java +++ /dev/null @@ -1,11 +0,0 @@ -package net.openan.a2at.sample.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/server/runtime/ServerBootstrapResult.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/ServerBootstrapResult.java deleted file mode 100644 index 838f1b85..00000000 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/ServerBootstrapResult.java +++ /dev/null @@ -1,18 +0,0 @@ -package net.openan.a2at.sample.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/FromTextProbe.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java new file mode 100644 index 00000000..5b6c3eb8 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/FromTextProbe.java @@ -0,0 +1,96 @@ +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; + +/** + * 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); + String templateUri = ServiceRecoverySampleInputs.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(String.valueOf(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..00a8770f --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/SampleResourceJson.java @@ -0,0 +1,57 @@ +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..e749ea85 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/ServiceRecoverySampleInputs.java @@ -0,0 +1,138 @@ +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"; + + /** 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; + + /** 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 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() {} + + /** + * 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 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 + */ + 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 topic}/{@code notificationDataFormat}) instead of the zh-CN slot names, + * demonstrating that the validation API fills parameters per the caller-provided schema rather than echoing + * template slots. + * + * @return parameter JSON schema for validation and parameter extraction + */ + 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..9fbb6ec6 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/ClientSampleMain.java @@ -0,0 +1,109 @@ +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/client/flow/A2AJavaClientEventMapper.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/A2AJavaClientEventMapper.java similarity index 91% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/A2AJavaClientEventMapper.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/A2AJavaClientEventMapper.java index 3a59477d..c5fd7f3a 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/A2AJavaClientEventMapper.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/A2AJavaClientEventMapper.java @@ -1,8 +1,8 @@ -package net.openan.a2at.sample.client.flow; +package net.openan.a2at.sample.service_recovery.client.flow; import java.util.List; import java.util.Map; -import net.openan.a2at.sample.shared.error.ValueErrorException; +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; @@ -22,8 +22,7 @@ */ public final class A2AJavaClientEventMapper { - private A2AJavaClientEventMapper() { - } + private A2AJavaClientEventMapper() {} public static Map toPayload(ClientEvent event) { if (event instanceof TaskEvent taskEvent) { @@ -33,7 +32,9 @@ public static Map toPayload(ClientEvent event) { } if (event instanceof TaskUpdateEvent taskUpdateEvent) { if (taskUpdateEvent.getUpdateEvent() instanceof TaskStatusUpdateEvent statusUpdateEvent) { - return Map.of("status", Map.of("state", statusUpdateEvent.status().state().name())); + return Map.of( + "status", + Map.of("state", statusUpdateEvent.status().state().name())); } if (taskUpdateEvent.getUpdateEvent() instanceof TaskArtifactUpdateEvent artifactUpdateEvent) { return Map.of("artifact", toArtifactMap(artifactUpdateEvent.artifact())); @@ -92,5 +93,3 @@ private static Map toPartMap(Part part) { return Map.of("value", String.valueOf(part)); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientEventStreamBuffer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientEventStreamBuffer.java similarity index 94% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientEventStreamBuffer.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientEventStreamBuffer.java index a6e16984..6270453c 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/ClientEventStreamBuffer.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientEventStreamBuffer.java @@ -1,10 +1,10 @@ -package net.openan.a2at.sample.client.flow; +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.shared.error.ValueErrorException; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; import org.a2aproject.sdk.client.ClientEvent; /** 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..51809515 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientFlowOutcome.java @@ -0,0 +1,14 @@ +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..2efd37ee --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/ClientSampleFlow.java @@ -0,0 +1,251 @@ +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 recovery plan execution status", + content.promptText() != null && content.promptText().contains("业务抢通方案执行状态"), + "promptText does not carry the required " + "业务抢通方案执行状态" + " 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/client/flow/SampleStreamTerminalStateDecider.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/SampleStreamTerminalStateDecider.java similarity index 93% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/SampleStreamTerminalStateDecider.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/SampleStreamTerminalStateDecider.java index 293b1c4c..5e57044d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/flow/SampleStreamTerminalStateDecider.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/flow/SampleStreamTerminalStateDecider.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.flow; +package net.openan.a2at.sample.service_recovery.client.flow; import org.a2aproject.sdk.client.ClientEvent; import org.a2aproject.sdk.client.TaskEvent; @@ -13,8 +13,7 @@ * @since 2026-05 */ public final class SampleStreamTerminalStateDecider { - private SampleStreamTerminalStateDecider() { - } + private SampleStreamTerminalStateDecider() {} public static boolean isTerminal(ClientEvent event) { if (event instanceof TaskEvent taskEvent) { @@ -40,5 +39,3 @@ private static boolean isTerminal(TaskState state) { || state == TaskState.TASK_STATE_AUTH_REQUIRED; } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/package-info.java similarity index 68% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/package-info.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/package-info.java index 9491a606..d53491fb 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/package-info.java @@ -3,5 +3,4 @@ * * @since 2026-05 */ -package net.openan.a2at.sample.client; - +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..92b1f0d5 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/prompt/A2ATNotificationPromptClient.java @@ -0,0 +1,38 @@ +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; + +/** + * {@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, templateUri); + } + + @Override + public MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, String templateUri) { + return delegate.generateNotificationPromptFromDataWithSchema(data, schema, 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..7d8a8b32 --- /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/client/registry/SampleRegistryClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/registry/SampleRegistryClient.java similarity index 78% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/registry/SampleRegistryClient.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/registry/SampleRegistryClient.java index b1387b7a..51bad905 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/registry/SampleRegistryClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/registry/SampleRegistryClient.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.registry; +package net.openan.a2at.sample.service_recovery.client.registry; import java.util.Map; @@ -11,5 +11,3 @@ public interface SampleRegistryClient { Map queryAgentCardByName(String name, String organization); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/request/A2AJavaRequestBuilder.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/A2AJavaRequestBuilder.java similarity index 91% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/request/A2AJavaRequestBuilder.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/A2AJavaRequestBuilder.java index dfda8f68..b8be8150 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/request/A2AJavaRequestBuilder.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/A2AJavaRequestBuilder.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.request; +package net.openan.a2at.sample.service_recovery.client.request; import java.util.Map; import java.util.UUID; @@ -14,8 +14,7 @@ */ public final class A2AJavaRequestBuilder { - private A2AJavaRequestBuilder() { - } + private A2AJavaRequestBuilder() {} public static BuiltA2AJavaRequest buildStreamRequest( String promptText, String extensionUri, String metadataPayload) { @@ -30,5 +29,3 @@ public static BuiltA2AJavaRequest buildStreamRequest( return new BuiltA2AJavaRequest(request, callContext); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/request/BuiltA2AJavaRequest.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/BuiltA2AJavaRequest.java similarity index 75% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/request/BuiltA2AJavaRequest.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/BuiltA2AJavaRequest.java index a3f1513d..ea87bb3f 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/request/BuiltA2AJavaRequest.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/request/BuiltA2AJavaRequest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.request; +package net.openan.a2at.sample.service_recovery.client.request; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; import org.a2aproject.sdk.spec.MessageSendParams; @@ -8,7 +8,4 @@ * * @since 2026-05 */ -public record BuiltA2AJavaRequest(MessageSendParams request, ClientCallContext callContext) { -} - - +public record BuiltA2AJavaRequest(MessageSendParams request, ClientCallContext callContext) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/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..32bf7c25 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/DefaultSampleClientRuntime.java @@ -0,0 +1,250 @@ +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/client/runtime/SampleClientRuntimeFactory.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntimeFactory.java similarity index 79% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/SampleClientRuntimeFactory.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntimeFactory.java index c596e7b4..98e1ed9d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/SampleClientRuntimeFactory.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/client/runtime/SampleClientRuntimeFactory.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.runtime; +package net.openan.a2at.sample.service_recovery.client.runtime; import java.nio.file.Path; @@ -11,5 +11,3 @@ public interface SampleClientRuntimeFactory { SampleClientRuntime create(Path envPath); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/package-info.java new file mode 100644 index 00000000..fb497717 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/package-info.java @@ -0,0 +1,18 @@ +/** + * 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..6562f45a --- /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..f4e65dd4 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/agentcard/ServerSampleAgentCardBuilder.java @@ -0,0 +1,77 @@ +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..d7fc9273 --- /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/server/flow/ServerFlowInterruptedException.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerFlowInterruptedException.java similarity index 82% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerFlowInterruptedException.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerFlowInterruptedException.java index 50f2a55e..b6c3a732 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerFlowInterruptedException.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerFlowInterruptedException.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.flow; +package net.openan.a2at.sample.service_recovery.server.flow; /** * Internal control exception used by tests to stop the recurring artifact loop. @@ -11,5 +11,3 @@ public ServerFlowInterruptedException(String message) { super(message); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleAgentExecutor.java new file mode 100644 index 00000000..fe522911 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleAgentExecutor.java @@ -0,0 +1,53 @@ +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..77b6b4ea --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSampleFlow.java @@ -0,0 +1,190 @@ +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/server/flow/ServerSleepController.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSleepController.java similarity index 77% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSleepController.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSleepController.java index 50fbc0c4..ef57a9d4 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSleepController.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/flow/ServerSleepController.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.flow; +package net.openan.a2at.sample.service_recovery.server.flow; /** * Controllable sleep abstraction used by the recurring mock stream loop. @@ -10,5 +10,3 @@ public interface ServerSleepController { void sleepSeconds(long delaySeconds); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/http/EmbeddedA2AHttpServer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/http/EmbeddedA2AHttpServer.java similarity index 97% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/http/EmbeddedA2AHttpServer.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/http/EmbeddedA2AHttpServer.java index 00753cec..16c2bfbe 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/http/EmbeddedA2AHttpServer.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/http/EmbeddedA2AHttpServer.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.http; +package net.openan.a2at.sample.service_recovery.server.http; import com.google.protobuf.util.JsonFormat; import com.sun.net.httpserver.HttpExchange; @@ -57,11 +57,7 @@ public static EmbeddedA2AHttpServer start( try { HttpServer server = HttpServer.create(new InetSocketAddress(host, port), 0); ExecutorService executorService = new ThreadPoolExecutor( - SERVER_THREAD_COUNT, - SERVER_THREAD_COUNT, - 0L, - TimeUnit.MILLISECONDS, - new LinkedBlockingQueue<>()); + SERVER_THREAD_COUNT, SERVER_THREAD_COUNT, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>()); server.setExecutor(executorService); RestHandler restHandler = new RestHandler( @@ -259,7 +255,9 @@ public void onNext(StreamingEventKind item) { try { String payload = JsonFormat.printer().print(toStreamResponse(item)); String encoded = formatSseFrame(sequence.incrementAndGet(), payload); - LOGGER.trace("[server] sse-write: {}", encoded.replace("\r", "\\r").replace("\n", "\\n")); + LOGGER.trace( + "[server] sse-write: {}", + encoded.replace("\r", "\\r").replace("\n", "\\n")); outputStream.write(encoded.getBytes(StandardCharsets.UTF_8)); outputStream.flush(); LOGGER.trace("[server] sse-write-flushed"); diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/package-info.java similarity index 68% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/package-info.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/package-info.java index 96a39316..0565033b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/package-info.java @@ -3,5 +3,4 @@ * * @since 2026-05 */ -package net.openan.a2at.sample.server; - +package net.openan.a2at.sample.service_recovery.server; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryClient.java similarity index 97% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryClient.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryClient.java index af31b7a7..fffd5b1b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryClient.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.registry; +package net.openan.a2at.sample.service_recovery.server.registry; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; @@ -16,7 +16,7 @@ import java.util.List; import java.util.Map; import java.util.function.Consumer; -import net.openan.a2at.sample.shared.error.ValueErrorException; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; /** * Registry registration support for the server sample. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryResponse.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryResponse.java similarity index 64% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryResponse.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryResponse.java index 6c08f03b..c438dafc 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryResponse.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryResponse.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.registry; +package net.openan.a2at.sample.service_recovery.server.registry; import java.util.Map; @@ -7,7 +7,4 @@ * * @since 2026-05 */ -public record ServerRegistryResponse(int statusCode, String text, Map jsonBody) { -} - - +public record ServerRegistryResponse(int statusCode, String text, Map jsonBody) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryTransport.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryTransport.java similarity index 79% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryTransport.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryTransport.java index 56ee45c9..5158f147 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/registry/ServerRegistryTransport.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/registry/ServerRegistryTransport.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.registry; +package net.openan.a2at.sample.service_recovery.server.registry; import java.util.Map; @@ -12,5 +12,3 @@ public interface ServerRegistryTransport { ServerRegistryResponse post(String url, Map payload); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/A2AJavaServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/A2AJavaServerRuntime.java similarity index 76% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/A2AJavaServerRuntime.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/A2AJavaServerRuntime.java index 600996e8..db1f54ab 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/A2AJavaServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/A2AJavaServerRuntime.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.runtime; +package net.openan.a2at.sample.service_recovery.server.runtime; /** * Transitional seam for assembling the real a2a-java REST server runtime. @@ -9,5 +9,3 @@ public interface A2AJavaServerRuntime { Object createRestApplication(String host, int port); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java new file mode 100644 index 00000000..905dcf4a --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntime.java @@ -0,0 +1,204 @@ +package net.openan.a2at.sample.service_recovery.server.runtime; + +import java.nio.file.Path; +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.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); + String templateUri = ServiceRecoverySampleInputs.TEMPLATE_URI; + return promptText -> server.validateNotificationPromptAndDataFilling( + 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/server/runtime/SampleServerRuntimeFactory.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntimeFactory.java similarity index 77% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/SampleServerRuntimeFactory.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntimeFactory.java index c360c409..6c764dd5 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/SampleServerRuntimeFactory.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/SampleServerRuntimeFactory.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.runtime; +package net.openan.a2at.sample.service_recovery.server.runtime; import java.nio.file.Path; @@ -12,5 +12,3 @@ public interface SampleServerRuntimeFactory { SampleServerRuntime create(Path envPath); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBind.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBind.java new file mode 100644 index 00000000..a1068f39 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBind.java @@ -0,0 +1,8 @@ +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..56f85cfa --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/server/runtime/ServerBootstrapResult.java @@ -0,0 +1,11 @@ +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/shared/endpoint/AgentEndpointResolver.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/AgentEndpointResolver.java similarity index 90% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/AgentEndpointResolver.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/AgentEndpointResolver.java index 1b7e8bde..653cb286 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/AgentEndpointResolver.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/AgentEndpointResolver.java @@ -1,9 +1,9 @@ -package net.openan.a2at.sample.shared.endpoint; +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.shared.error.ValueErrorException; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; /** * Resolves the preferred HTTP endpoint from a registry AgentCard payload. @@ -12,8 +12,7 @@ */ public final class AgentEndpointResolver { - private AgentEndpointResolver() { - } + private AgentEndpointResolver() {} public static ResolvedAgentEndpoint resolvePreferredInterface(Map agentCard) { Object supportedInterfacesValue = agentCard.get("supportedInterfaces"); @@ -52,5 +51,3 @@ private static String stringValue(Object value) { return value == null ? "" : String.valueOf(value); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/ResolvedAgentEndpoint.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/ResolvedAgentEndpoint.java new file mode 100644 index 00000000..5078b5e7 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/endpoint/ResolvedAgentEndpoint.java @@ -0,0 +1,8 @@ +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/shared/env/SampleEnvironmentPathResolver.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/env/SampleEnvironmentPathResolver.java similarity index 85% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/env/SampleEnvironmentPathResolver.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/env/SampleEnvironmentPathResolver.java index 249891ee..e213e62d 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/env/SampleEnvironmentPathResolver.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/env/SampleEnvironmentPathResolver.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.shared.env; +package net.openan.a2at.sample.service_recovery.shared.env; import java.nio.file.Files; import java.nio.file.Path; @@ -10,8 +10,7 @@ */ public final class SampleEnvironmentPathResolver { - private SampleEnvironmentPathResolver() { - } + private SampleEnvironmentPathResolver() {} public static Path resolve(Path directory, String primaryFileName, String fallbackFileName) { Path primary = directory.resolve(primaryFileName); @@ -26,5 +25,3 @@ public static Path resolve(Path directory, String primaryFileName, String fallba return primary; } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/error/ValueErrorException.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/error/ValueErrorException.java similarity index 85% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/error/ValueErrorException.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/error/ValueErrorException.java index 0d52893a..442d177b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/error/ValueErrorException.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/error/ValueErrorException.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.shared.error; +package net.openan.a2at.sample.service_recovery.shared.error; /** * Stable runtime exception used by the sample support layer for invalid sample inputs. diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/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..deb78529 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/logging/SampleLoggingFormatter.java @@ -0,0 +1,145 @@ +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..b9bcd98c --- /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..aaed689b --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLLMClient.java @@ -0,0 +1,100 @@ +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..aa11f2dd --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/mock/SampleMockLlmInstaller.java @@ -0,0 +1,172 @@ +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); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/package-info.java similarity index 62% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/package-info.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/package-info.java index e4ca4345..c8a8d5ce 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/package-info.java @@ -3,5 +3,4 @@ * * @since 2026-05 */ -package net.openan.a2at.sample.shared; - +package net.openan.a2at.sample.service_recovery.shared; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/registry/RegistryAgentCardMapper.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/registry/RegistryAgentCardMapper.java similarity index 63% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/registry/RegistryAgentCardMapper.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/registry/RegistryAgentCardMapper.java index 252e8848..73917393 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/registry/RegistryAgentCardMapper.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/registry/RegistryAgentCardMapper.java @@ -1,9 +1,9 @@ -package net.openan.a2at.sample.shared.registry; +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.AgentCard; import org.a2aproject.sdk.spec.AgentExtension; import org.a2aproject.sdk.spec.AgentInterface; import org.a2aproject.sdk.spec.AgentProvider; @@ -16,8 +16,7 @@ */ public final class RegistryAgentCardMapper { - private RegistryAgentCardMapper() { - } + private RegistryAgentCardMapper() {} public static AgentCard toA2AJavaAgentCard(Map registryAgentCard) { @SuppressWarnings("unchecked") @@ -25,16 +24,21 @@ public static AgentCard toA2AJavaAgentCard(Map registryAgentCard @SuppressWarnings("unchecked") Map capabilities = (Map) registryAgentCard.get("capabilities"); @SuppressWarnings("unchecked") - List> extensionMaps = (List>) capabilities.getOrDefault("extensions", List.of()); + List> extensionMaps = + (List>) capabilities.getOrDefault("extensions", List.of()); @SuppressWarnings("unchecked") - List> skillMaps = (List>) registryAgentCard.getOrDefault("skills", List.of()); + List> skillMaps = + (List>) registryAgentCard.getOrDefault("skills", List.of()); @SuppressWarnings("unchecked") List> interfaceMaps = (List>) registryAgentCard.getOrDefault("supportedInterfaces", List.of()); return new AgentCard( stringValue(registryAgentCard.get("name")), stringValue(registryAgentCard.get("description")), - provider == null ? null : new AgentProvider(stringValue(provider.get("organization")), stringValue(provider.get("url"))), + provider == null + ? null + : new AgentProvider( + stringValue(provider.get("organization")), stringValue(provider.get("url"))), stringValue(registryAgentCard.get("version")), null, new AgentCapabilities( @@ -85,30 +89,35 @@ public static Map toRegistryAgentCard(AgentCard agentCard) { "version", agentCard.version(), "defaultInputModes", agentCard.defaultInputModes(), "defaultOutputModes", agentCard.defaultOutputModes(), - "provider", Map.of( - "organization", agentCard.provider().organization(), - "url", agentCard.provider().url()), - "skills", agentCard.skills().stream() - .map(skill -> Map.of( - "id", skill.id(), - "name", skill.name(), - "description", skill.description(), - "tags", skill.tags())) - .toList(), - "capabilities", Map.of( - "streaming", agentCard.capabilities().streaming(), - "pushNotifications", agentCard.capabilities().pushNotifications(), - "extensions", agentCard.capabilities().extensions().stream() - .map(extension -> Map.of( - "uri", extension.uri(), - "description", extension.description())) - .toList()), - "supportedInterfaces", agentCard.supportedInterfaces().stream() - .map(agentInterface -> Map.of( - "protocolBinding", agentInterface.protocolBinding(), - "protocolVersion", agentInterface.protocolVersion(), - "url", agentInterface.url())) - .toList()); + "provider", + Map.of( + "organization", agentCard.provider().organization(), + "url", agentCard.provider().url()), + "skills", + agentCard.skills().stream() + .map(skill -> Map.of( + "id", skill.id(), + "name", skill.name(), + "description", skill.description(), + "tags", skill.tags())) + .toList(), + "capabilities", + Map.of( + "streaming", agentCard.capabilities().streaming(), + "pushNotifications", agentCard.capabilities().pushNotifications(), + "extensions", + agentCard.capabilities().extensions().stream() + .map(extension -> Map.of( + "uri", extension.uri(), + "description", extension.description())) + .toList()), + "supportedInterfaces", + agentCard.supportedInterfaces().stream() + .map(agentInterface -> Map.of( + "protocolBinding", agentInterface.protocolBinding(), + "protocolVersion", agentInterface.protocolVersion(), + "url", agentInterface.url())) + .toList()); } private static List stringList(Object value) { @@ -122,5 +131,3 @@ private static String stringValue(Object value) { return value == null ? "" : String.valueOf(value); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/stream/SampleStreamEventNormalizer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/stream/SampleStreamEventNormalizer.java similarity index 88% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/stream/SampleStreamEventNormalizer.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/stream/SampleStreamEventNormalizer.java index bb82731f..0f1b91d4 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/stream/SampleStreamEventNormalizer.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/service_recovery/shared/stream/SampleStreamEventNormalizer.java @@ -1,8 +1,8 @@ -package net.openan.a2at.sample.shared.stream; +package net.openan.a2at.sample.service_recovery.shared.stream; import java.util.List; import java.util.Map; -import net.openan.a2at.sample.shared.error.ValueErrorException; +import net.openan.a2at.sample.service_recovery.shared.error.ValueErrorException; /** * Normalizes raw stream payload fragments into stable sample event shapes. @@ -11,8 +11,7 @@ */ public final class SampleStreamEventNormalizer { - private SampleStreamEventNormalizer() { - } + private SampleStreamEventNormalizer() {} public static Map normalize(Map payload) { if (payload == null || payload.isEmpty()) { @@ -44,5 +43,3 @@ public static Map normalize(Map payload) { throw new ValueErrorException("Unsupported SSE payload: " + payload); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/ResolvedAgentEndpoint.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/ResolvedAgentEndpoint.java deleted file mode 100644 index f308bc7b..00000000 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/ResolvedAgentEndpoint.java +++ /dev/null @@ -1,12 +0,0 @@ -package net.openan.a2at.sample.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/subscribe_incident/client/ClientSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMain.java new file mode 100644 index 00000000..2aa69005 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMain.java @@ -0,0 +1,110 @@ +package net.openan.a2at.sample.subscribe_incident.client; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sample.subscribe_incident.client.flow.ClientSampleFlow; +import net.openan.a2at.sample.subscribe_incident.client.runtime.A2AJavaClientRuntime; +import net.openan.a2at.sample.subscribe_incident.client.runtime.DefaultSampleClientRuntime; +import net.openan.a2at.sample.subscribe_incident.client.runtime.SampleClientRuntime; +import net.openan.a2at.sample.subscribe_incident.client.runtime.SampleClientRuntimeFactory; +import net.openan.a2at.sample.subscribe_incident.shared.mock.SampleMockLlmInstaller; +import net.openan.a2at.sample.subscribe_incident.shared.scenario.SampleScenarioLoader; + +/** + * Main entry orchestration for the client sample. + * + * @since 2026-05 + */ +public final class ClientSampleMain { + + private static final String MOCK_RESOURCE_ROOT = "sample/subscribe-incident/mock_responses"; + + private ClientSampleMain() {} + + public static Path resolveEnvPath(String[] args) { + return args.length > 0 ? Path.of(args[0]) : DefaultSampleClientRuntime.resolveDefaultEnvPath(); + } + + public static List> runMain(Path envPath, SampleClientRuntimeFactory runtimeFactory) { + return runMain(envPath, runtimeFactory, null); + } + + public static List> 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"); + } + Map scenarioPayload = + SampleScenarioLoader.loadClasspathScenario("sample/subscribe-incident/client/scenario.json"); + return ClientSampleFlow.runClientFlow( + scenarioPayload, + runtime.registryClient(), + runtime.promptClient(), + a2aRuntime, + runtime.endpointCache(), + logSink, + resolveMaxArtifacts(), + resolveLanguage(resolvedEnvPath)); + } finally { + runtime.close(); + } + } + + public static void main(String[] args) { + runMain(resolveEnvPath(args), DefaultSampleClientRuntime::new, System.out::println); + } + + private static int resolveMaxArtifacts() { + String raw = System.getenv("A2AT_SAMPLE_MAX_ARTIFACTS"); + if (raw != null) { + String trimmed = raw.trim(); + if (!trimmed.isEmpty()) { + try { + return Integer.parseInt(trimmed); + } catch (NumberFormatException exception) { + // ignore and fall through to default + } + } + } + return 0; + } + + private static String resolveLanguage(Path envPath) { + if (envPath == null || !java.nio.file.Files.exists(envPath)) { + return "zh-CN"; + } + try { + for (String rawLine : java.nio.file.Files.readAllLines(envPath)) { + String line = rawLine.trim(); + if (line.startsWith("\uFEFF")) { + line = line.substring(1).trim(); + } + if (line.startsWith("A2AT_LANGUAGE=") && line.length() > "A2AT_LANGUAGE=".length()) { + String value = line.substring("A2AT_LANGUAGE=".length()).trim(); + if (!value.isEmpty()) { + return value; + } + } + } + } catch (java.io.IOException exception) { + // fall through to default + } + return "zh-CN"; + } + + private static void emit(Consumer logSink, String message) { + if (logSink != null) { + logSink.accept(message); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/ClientSampleMarker.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMarker.java similarity index 56% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/ClientSampleMarker.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMarker.java index 58cfe543..3f6f2356 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/ClientSampleMarker.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/ClientSampleMarker.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client; +package net.openan.a2at.sample.subscribe_incident.client; /** * Marker type for client sample entrypoint code. @@ -7,7 +7,5 @@ */ public final class ClientSampleMarker { - private ClientSampleMarker() { - } + private ClientSampleMarker() {} } - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/A2AJavaClientEventMapper.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/A2AJavaClientEventMapper.java new file mode 100644 index 00000000..289dfa93 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/A2AJavaClientEventMapper.java @@ -0,0 +1,95 @@ +package net.openan.a2at.sample.subscribe_incident.client.flow; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import org.a2aproject.sdk.client.ClientEvent; +import org.a2aproject.sdk.client.MessageEvent; +import org.a2aproject.sdk.client.TaskEvent; +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/subscribe_incident/client/flow/ClientEventStreamBuffer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientEventStreamBuffer.java new file mode 100644 index 00000000..64dcbe03 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientEventStreamBuffer.java @@ -0,0 +1,84 @@ +package net.openan.a2at.sample.subscribe_incident.client.flow; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import net.openan.a2at.sample.subscribe_incident.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/subscribe_incident/client/flow/ClientSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientSampleFlow.java new file mode 100644 index 00000000..7918efa2 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/ClientSampleFlow.java @@ -0,0 +1,201 @@ +package net.openan.a2at.sample.subscribe_incident.client.flow; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sample.subscribe_incident.client.prompt.SamplePromptClient; +import net.openan.a2at.sample.subscribe_incident.client.registry.SampleRegistryClient; +import net.openan.a2at.sample.subscribe_incident.client.request.A2AJavaRequestBuilder; +import net.openan.a2at.sample.subscribe_incident.client.request.BuiltA2AJavaRequest; +import net.openan.a2at.sample.subscribe_incident.client.runtime.A2AJavaClientRuntime; +import net.openan.a2at.sample.subscribe_incident.shared.endpoint.AgentEndpointCache; +import net.openan.a2at.sample.subscribe_incident.shared.endpoint.AgentEndpointResolver; +import net.openan.a2at.sample.subscribe_incident.shared.endpoint.ResolvedAgentEndpoint; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.logging.SampleLoggingFormatter; +import net.openan.a2at.sample.subscribe_incident.shared.stream.SampleStreamEventNormalizer; +import net.openan.a2at.sdk.client.model.PromptGenerationFailure; +import net.openan.a2at.sdk.client.model.PromptGenerationResult; +import org.a2aproject.sdk.client.ClientEvent; + +/** + * Core client-side orchestration for the publishable sample module. + * + * @since 2026-05 + */ +public final class ClientSampleFlow { + static final String NOTIFICATION_T_EXTENSION_URI_NL = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/NL/v1"; + + static final String NATURAL_LANGUAGE_PROMPT_INPUT_ZH = + "请生成一个Incident事件订阅任务:通知主题为Incident,订阅条件为订阅级别为critical的ETH-LOS的故障,上报通知数据格式为DataPart"; + + static final String NATURAL_LANGUAGE_PROMPT_INPUT_EN = + "Generate an Incident event subscription task: notification topic is Incident, " + + "subscription condition is a critical ETH-LOS fault, " + + "and the notification data format is DataPart"; + + private static final String LANGUAGE_EN_US = "en-US"; + + private ClientSampleFlow() {} + + public static List> runClientFlow( + Map scenarioPayload, + SampleRegistryClient registryClient, + SamplePromptClient promptClient, + A2AJavaClientRuntime a2aRuntime, + AgentEndpointCache endpointCache, + Consumer logSink) { + return runClientFlow( + scenarioPayload, registryClient, promptClient, a2aRuntime, endpointCache, logSink, 0, "zh-CN"); + } + + public static List> runClientFlow( + Map scenarioPayload, + SampleRegistryClient registryClient, + SamplePromptClient promptClient, + A2AJavaClientRuntime a2aRuntime, + AgentEndpointCache endpointCache, + Consumer logSink, + int maxArtifacts) { + return runClientFlow( + scenarioPayload, + registryClient, + promptClient, + a2aRuntime, + endpointCache, + logSink, + maxArtifacts, + "zh-CN"); + } + + public static List> runClientFlow( + Map scenarioPayload, + SampleRegistryClient registryClient, + SamplePromptClient promptClient, + A2AJavaClientRuntime a2aRuntime, + AgentEndpointCache endpointCache, + Consumer logSink, + int maxArtifacts, + String language) { + AgentCardQuery query = resolveAgentCardQuery(scenarioPayload); + Map agentCard = registryClient.queryAgentCardByName(query.name(), query.organization()); + emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "agentcard-result", agentCard)); + + ResolvedAgentEndpoint endpoint = AgentEndpointResolver.resolvePreferredInterface(agentCard); + endpointCache.setResolved(endpoint); + emit( + logSink, + SampleLoggingFormatter.formatStageLog( + "client", + "agentcard-resolved", + "url=" + endpoint.url() + " binding=" + endpoint.protocolBinding())); + + String promptInput = buildPromptInput(language); + emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "prompt-input", promptInput)); + PromptGenerationResult promptResult = promptClient.generateTaskPrompt(promptInput); + emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "prompt-generation-result", promptResult)); + String promptText = requirePromptText(promptResult); + + BuiltA2AJavaRequest builtRequest = A2AJavaRequestBuilder.buildStreamRequest( + promptText, NOTIFICATION_T_EXTENSION_URI_NL, buildRequestMetadata(scenarioPayload)); + emit(logSink, SampleLoggingFormatter.formatPayloadLog("client", "a2a-request-body", builtRequest.request())); + emit( + logSink, + SampleLoggingFormatter.formatPayloadLog( + "client", "request-headers", builtRequest.callContext().getHeaders())); + emit( + logSink, + SampleLoggingFormatter.formatStageLog( + "client", + "request-built", + "extension=" + builtRequest.callContext().getHeaders().get("A2A-Extensions"))); + + List> normalizedEvents = new ArrayList<>(); + int artifactCount = 0; + for (ClientEvent rawEvent : + a2aRuntime.sendMessage(agentCard, builtRequest.request(), builtRequest.callContext(), logSink)) { + Map payload = A2AJavaClientEventMapper.toPayload(rawEvent); + Map normalizedEvent = SampleStreamEventNormalizer.normalize(payload); + normalizedEvents.add(normalizedEvent); + if ("status".equals(normalizedEvent.get("kind"))) { + emit( + logSink, + SampleLoggingFormatter.formatStageLog( + "client", "task-status", String.valueOf(normalizedEvent.get("state")))); + } else if ("message".equals(normalizedEvent.get("kind"))) { + emit( + logSink, + SampleLoggingFormatter.formatStageLog( + "client", "task-message", String.valueOf(normalizedEvent.get("text")))); + } else if ("artifact".equals(normalizedEvent.get("kind"))) { + emit( + logSink, + SampleLoggingFormatter.formatPayloadLog( + "client", "task-artifact", normalizedEvent.get("artifact"))); + artifactCount++; + if (maxArtifacts > 0 && artifactCount >= maxArtifacts) { + emit(logSink, "[client] max-artifacts-reached: " + maxArtifacts); + break; + } + } + } + return normalizedEvents; + } + + private static void emit(Consumer logSink, String message) { + if (logSink != null) { + logSink.accept(message); + } + } + + private static String requirePromptText(PromptGenerationResult promptResult) { + if (promptResult.promptText() != null) { + return promptResult.promptText(); + } + PromptGenerationFailure failure = promptResult.failure(); + String code = failure == null ? "PROMPT_GENERATION_FAILED" : failure.code(); + String message = failure == null ? "prompt generation did not produce text" : failure.message(); + throw new ValueErrorException(code + ": " + message); + } + + private static String buildRequestMetadata(Map scenarioPayload) { + return String.valueOf(scenarioPayload.getOrDefault("scenario", "")); + } + + private static String buildPromptInput(String language) { + return LANGUAGE_EN_US.equalsIgnoreCase(language) + ? NATURAL_LANGUAGE_PROMPT_INPUT_EN + : NATURAL_LANGUAGE_PROMPT_INPUT_ZH; + } + + private static AgentCardQuery resolveAgentCardQuery(Map scenarioPayload) { + Object queryValue = scenarioPayload.get("agent_card_query"); + if (queryValue instanceof Map queryMap) { + String name = stringOrDefault(queryMap.get("name"), "RAN Energy Saving Agent"); + String organization = stringOrDefault(queryMap.get("organization"), "Huawei"); + if (name.isBlank()) { + throw new ValueErrorException("AgentCard query name must not be empty"); + } + return new AgentCardQuery(name, organization.isBlank() ? "Huawei" : organization); + } + + String name = stringOrDefault(scenarioPayload.get("agent_card_name"), "RAN Energy Saving Agent"); + String organization = stringOrDefault(scenarioPayload.get("organization"), "Huawei"); + if (name.isBlank()) { + throw new ValueErrorException("AgentCard query name must not be empty"); + } + return new AgentCardQuery(name, organization.isBlank() ? "Huawei" : organization); + } + + private static String stringOrDefault(Object value, String defaultValue) { + if (value == null) { + return defaultValue; + } + String resolved = String.valueOf(value).trim(); + return resolved.isEmpty() ? defaultValue : resolved; + } + + private record AgentCardQuery(String name, String organization) {} +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/SampleStreamTerminalStateDecider.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/SampleStreamTerminalStateDecider.java new file mode 100644 index 00000000..5df7dbc5 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/flow/SampleStreamTerminalStateDecider.java @@ -0,0 +1,41 @@ +package net.openan.a2at.sample.subscribe_incident.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/subscribe_incident/client/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/package-info.java new file mode 100644 index 00000000..62d006c0 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/package-info.java @@ -0,0 +1,6 @@ +/** + * Client-side sample entrypoints and orchestration for the publishable a2a-t sample module. + * + * @since 2026-05 + */ +package net.openan.a2at.sample.subscribe_incident.client; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/prompt/SamplePromptClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptClient.java similarity index 80% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/prompt/SamplePromptClient.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptClient.java index fb9d5a2b..2a32dab9 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/prompt/SamplePromptClient.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptClient.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.prompt; +package net.openan.a2at.sample.subscribe_incident.client.prompt; import net.openan.a2at.sdk.client.model.PromptGenerationResult; @@ -11,5 +11,3 @@ public interface SamplePromptClient { PromptGenerationResult generateTaskPrompt(Object userInput); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/prompt/SamplePromptInputAdapter.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptInputAdapter.java similarity index 87% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/prompt/SamplePromptInputAdapter.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptInputAdapter.java index 1b600338..a0698476 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/prompt/SamplePromptInputAdapter.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/prompt/SamplePromptInputAdapter.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.prompt; +package net.openan.a2at.sample.subscribe_incident.client.prompt; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -18,16 +18,16 @@ public final class SamplePromptInputAdapter { private static final String SLOT_REPORT_FORMAT = "上报通知数据格式"; - private SamplePromptInputAdapter() { - } + private SamplePromptInputAdapter() {} public static Map adapt(Map scenarioPayload) { Map adapted = new LinkedHashMap<>(scenarioPayload); adapted.put(SLOT_TOPIC, stringOrDefault(scenarioPayload.get(SLOT_TOPIC), "Incident")); - adapted.put(SLOT_CONDITION, stringOrDefault(scenarioPayload.get(SLOT_CONDITION), buildConditionText(scenarioPayload))); - adapted.put(SLOT_REPORT_FORMAT, stringOrDefault( - scenarioPayload.get(SLOT_REPORT_FORMAT), - "通过DataPart上报Incident数据")); + adapted.put( + SLOT_CONDITION, + stringOrDefault(scenarioPayload.get(SLOT_CONDITION), buildConditionText(scenarioPayload))); + adapted.put( + SLOT_REPORT_FORMAT, stringOrDefault(scenarioPayload.get(SLOT_REPORT_FORMAT), "通过DataPart上报Incident数据")); return adapted; } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/registry/SampleRegistryClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/registry/SampleRegistryClient.java new file mode 100644 index 00000000..8c11d971 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/registry/SampleRegistryClient.java @@ -0,0 +1,13 @@ +package net.openan.a2at.sample.subscribe_incident.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/subscribe_incident/client/request/A2AJavaRequestBuilder.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/A2AJavaRequestBuilder.java new file mode 100644 index 00000000..386a1cc3 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/A2AJavaRequestBuilder.java @@ -0,0 +1,31 @@ +package net.openan.a2at.sample.subscribe_incident.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/subscribe_incident/client/request/BuiltA2AJavaRequest.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/BuiltA2AJavaRequest.java new file mode 100644 index 00000000..7952b3fa --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/request/BuiltA2AJavaRequest.java @@ -0,0 +1,11 @@ +package net.openan.a2at.sample.subscribe_incident.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/client/runtime/A2AJavaClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/A2AJavaClientRuntime.java similarity index 90% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/A2AJavaClientRuntime.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/A2AJavaClientRuntime.java index d3144fc9..6074d927 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/A2AJavaClientRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/A2AJavaClientRuntime.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.runtime; +package net.openan.a2at.sample.subscribe_incident.client.runtime; import java.util.Map; import java.util.function.Consumer; @@ -21,5 +21,3 @@ Iterable sendMessage( ClientCallContext callContext, Consumer logSink); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/DefaultSampleClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java similarity index 82% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/DefaultSampleClientRuntime.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java index b7881634..1432d47f 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/client/runtime/DefaultSampleClientRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/DefaultSampleClientRuntime.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.client.runtime; +package net.openan.a2at.sample.subscribe_incident.client.runtime; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -13,14 +13,14 @@ import java.util.List; import java.util.Map; import java.util.function.Consumer; -import net.openan.a2at.sample.client.flow.ClientEventStreamBuffer; -import net.openan.a2at.sample.client.flow.SampleStreamTerminalStateDecider; -import net.openan.a2at.sample.client.prompt.SamplePromptClient; -import net.openan.a2at.sample.client.registry.SampleRegistryClient; -import net.openan.a2at.sample.shared.endpoint.AgentEndpointCache; -import net.openan.a2at.sample.shared.env.SampleEnvironmentPathResolver; -import net.openan.a2at.sample.shared.error.ValueErrorException; -import net.openan.a2at.sample.shared.registry.RegistryAgentCardMapper; +import net.openan.a2at.sample.subscribe_incident.client.flow.ClientEventStreamBuffer; +import net.openan.a2at.sample.subscribe_incident.client.flow.SampleStreamTerminalStateDecider; +import net.openan.a2at.sample.subscribe_incident.client.prompt.SamplePromptClient; +import net.openan.a2at.sample.subscribe_incident.client.registry.SampleRegistryClient; +import net.openan.a2at.sample.subscribe_incident.shared.endpoint.AgentEndpointCache; +import net.openan.a2at.sample.subscribe_incident.shared.env.SampleEnvironmentPathResolver; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.registry.RegistryAgentCardMapper; import net.openan.a2at.sdk.client.A2ATClient; import org.a2aproject.sdk.client.Client; import org.a2aproject.sdk.client.ClientEvent; @@ -61,7 +61,8 @@ public DefaultSampleClientRuntime(Path envPath) { } public static Path resolveDefaultEnvPath() { - Path sampleEnvDir = Path.of("a2a-t-sample","src", "main", "resources", "sample", "client"); + Path sampleEnvDir = + Path.of("a2a-t-sample", "src", "main", "resources", "sample", "subscribe-incident", "client"); return SampleEnvironmentPathResolver.resolve(sampleEnvDir, "client.env", "client.env"); } @@ -81,8 +82,7 @@ public AgentEndpointCache endpointCache() { } @Override - public void close() { - } + public void close() {} @Override public Object createStreamingClient(String agentBaseUrl) { @@ -160,8 +160,8 @@ public Map queryAgentCardByName(String name, String organization .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) { - throw new ValueErrorException( - "AgentCard query by name failed: status=" + response.statusCode() + " body=" + response.body()); + throw new ValueErrorException("AgentCard query by name failed: status=" + response.statusCode() + + " body=" + response.body()); } Map payload = parseObject(response.body()); Object agentCards = payload.get("agentCards"); @@ -172,9 +172,9 @@ public Map queryAgentCardByName(String name, String organization } throw new ValueErrorException("Registry query by name returned no AgentCard entries"); } catch (IOException exception) { - throw new ValueErrorException("Registry query by name failed: " + exception.getMessage()); + throw new ValueErrorException("Registry query by name failed: " + errorMessage(exception)); } catch (InterruptedException exception) { - throw new ValueErrorException("Registry query by name was interrupted: " + exception.getMessage()); + throw new ValueErrorException("Registry query by name was interrupted: " + errorMessage(exception)); } } @@ -187,17 +187,17 @@ private Map queryAgentCardFromServerRoot(String agentCardUrl) { .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 400) { - throw new ValueErrorException( - "AgentCard query from server root failed: status=" - + response.statusCode() - + " body=" - + response.body()); + throw new ValueErrorException("AgentCard query from server root failed: status=" + + response.statusCode() + + " body=" + + response.body()); } return parseObject(response.body()); } catch (IOException exception) { - throw new ValueErrorException("AgentCard query from server root failed: " + exception.getMessage()); + throw new ValueErrorException("AgentCard query from server root failed: " + errorMessage(exception)); } catch (InterruptedException exception) { - throw new ValueErrorException("AgentCard query from server root was interrupted: " + exception.getMessage()); + throw new ValueErrorException( + "AgentCard query from server root was interrupted: " + errorMessage(exception)); } } } @@ -216,6 +216,11 @@ static String encodePathSegment(String value) { } } + private static String errorMessage(Throwable throwable) { + String message = throwable.getMessage(); + return message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message; + } + private Object createStreamingClient(Map agentCard) { AgentCard realAgentCard = RegistryAgentCardMapper.toA2AJavaAgentCard(agentCard); try { @@ -256,7 +261,8 @@ private static long resolveTimeoutSeconds(Path envPath) { private static Map parseEnvFile(Path envPath) { try { - List lines = java.nio.file.Files.exists(envPath) ? java.nio.file.Files.readAllLines(envPath) : List.of(); + List lines = + java.nio.file.Files.exists(envPath) ? java.nio.file.Files.readAllLines(envPath) : List.of(); Map values = new LinkedHashMap<>(); for (String line : lines) { String trimmed = line.trim(); @@ -264,7 +270,9 @@ private static Map parseEnvFile(Path envPath) { continue; } int separatorIndex = trimmed.indexOf('='); - values.put(trimmed.substring(0, separatorIndex).trim(), trimmed.substring(separatorIndex + 1).trim()); + values.put( + trimmed.substring(0, separatorIndex).trim(), + trimmed.substring(separatorIndex + 1).trim()); } return values; } catch (IOException exception) { @@ -273,9 +281,6 @@ private static Map parseEnvFile(Path envPath) { } private static Map parseObject(String text) throws IOException { - return OBJECT_MAPPER.readValue(text, new TypeReference>() { - }); + return OBJECT_MAPPER.readValue(text, new TypeReference>() {}); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntime.java new file mode 100644 index 00000000..8ef3e998 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntime.java @@ -0,0 +1,22 @@ +package net.openan.a2at.sample.subscribe_incident.client.runtime; + +import net.openan.a2at.sample.subscribe_incident.client.prompt.SamplePromptClient; +import net.openan.a2at.sample.subscribe_incident.client.registry.SampleRegistryClient; +import net.openan.a2at.sample.subscribe_incident.shared.endpoint.AgentEndpointCache; + +/** + * Runtime bundle required by the client sample main flow. + * + * @since 2026-05 + */ +public interface SampleClientRuntime extends AutoCloseable { + + SampleRegistryClient registryClient(); + + SamplePromptClient promptClient(); + + AgentEndpointCache endpointCache(); + + @Override + void close(); +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntimeFactory.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntimeFactory.java new file mode 100644 index 00000000..2023783a --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/client/runtime/SampleClientRuntimeFactory.java @@ -0,0 +1,13 @@ +package net.openan.a2at.sample.subscribe_incident.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/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/package-info.java similarity index 66% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/package-info.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/package-info.java index 3761095d..c935239c 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/package-info.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/package-info.java @@ -3,5 +3,4 @@ * * @since 2026-05 */ -package net.openan.a2at.sample; - +package net.openan.a2at.sample.subscribe_incident; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java new file mode 100644 index 00000000..83498171 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMain.java @@ -0,0 +1,88 @@ +package net.openan.a2at.sample.subscribe_incident.server; + +import java.nio.file.Path; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sample.subscribe_incident.server.agentcard.ServerSampleAgentCardBuilder; +import net.openan.a2at.sample.subscribe_incident.server.flow.PromptComplianceChecker; +import net.openan.a2at.sample.subscribe_incident.server.http.EmbeddedA2AHttpServer; +import net.openan.a2at.sample.subscribe_incident.server.runtime.A2AJavaServerRuntime; +import net.openan.a2at.sample.subscribe_incident.server.runtime.DefaultSampleServerRuntime; +import net.openan.a2at.sample.subscribe_incident.server.runtime.SampleServerRuntime; +import net.openan.a2at.sample.subscribe_incident.server.runtime.SampleServerRuntimeFactory; +import net.openan.a2at.sample.subscribe_incident.server.runtime.ServerBind; +import net.openan.a2at.sample.subscribe_incident.server.runtime.ServerBootstrapResult; +import net.openan.a2at.sample.subscribe_incident.shared.mock.SampleMockLlmInstaller; +import net.openan.a2at.sample.subscribe_incident.shared.registry.RegistryAgentCardMapper; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; + +/** + * Main entry orchestration for the server sample. + * + * @since 2026-05 + */ +public final class ServerSampleMain { + + private static final String MOCK_RESOURCE_ROOT = "sample/subscribe-incident/mock_responses"; + + private ServerSampleMain() {} + + public static Path resolveEnvPath(String[] args) { + return args.length > 0 ? Path.of(args[0]) : DefaultSampleServerRuntime.resolveDefaultEnvPath(); + } + + public static ServerBootstrapResult runMain( + Path envPath, SampleServerRuntimeFactory runtimeFactory, Consumer logSink) { + boolean mockNeeded = SampleMockLlmInstaller.isMockNeeded(envPath); + SampleMockLlmInstaller.installLlmLogger(mockNeeded, "server"); + Path resolvedEnvPath = SampleMockLlmInstaller.resolveEnvPath(envPath, MOCK_RESOURCE_ROOT); + if (!resolvedEnvPath.equals(envPath)) { + emit(logSink, "[server] no LLM API key found, using mock LLM responses for e2e"); + } + SampleServerRuntime runtime = runtimeFactory.create(resolvedEnvPath); + ServerBind bind = runtime.resolveBind(); + emit(logSink, "[server] startup: host=" + bind.host() + " port=" + bind.port()); + Map agentCard = runtime.buildAgentCard(bind.host(), bind.port()); + PromptComplianceChecker promptChecker = runtime.buildPromptChecker(resolvedEnvPath); + Object app = runtime.buildApp(agentCard, promptChecker); + Map registrationPayload; + if (runtime instanceof A2AJavaServerRuntime a2aJavaServerRuntime + && a2aJavaServerRuntime.createRestApplication(bind.host(), bind.port()) + instanceof org.a2aproject.sdk.spec.AgentCard agentCardModel) { + registrationPayload = RegistryAgentCardMapper.toRegistryRegistrationPayload(agentCardModel); + } else { + registrationPayload = ServerSampleAgentCardBuilder.buildRegistrationPayload(bind.host(), bind.port()); + } + Map registrationResult = runtime.registerAgentCard(registrationPayload, resolvedEnvPath); + if ("success".equals(registrationResult.get("status"))) { + emit(logSink, "[server] agent-card registration: success"); + } else { + emit( + logSink, + "[server] agent-card registration: skipped (no registry-center available, " + + "client direct mode is used instead)"); + } + AutoCloseable serverHandle = null; + if (runtime instanceof A2AJavaServerRuntime a2aJavaServerRuntime + && app instanceof RequestHandler requestHandler + && a2aJavaServerRuntime.createRestApplication(bind.host(), bind.port()) + instanceof org.a2aproject.sdk.spec.AgentCard agentCardModel) { + serverHandle = EmbeddedA2AHttpServer.start(bind.host(), bind.port(), agentCardModel, requestHandler); + emit(logSink, "[server] http server started: http://" + bind.host() + ":" + bind.port()); + } + return new ServerBootstrapResult(bind.host(), bind.port(), app, serverHandle, registrationResult); + } + + 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(message); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/ServerSampleMarker.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMarker.java similarity index 56% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/ServerSampleMarker.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMarker.java index 108b7fe5..7a490eea 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/ServerSampleMarker.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/ServerSampleMarker.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server; +package net.openan.a2at.sample.subscribe_incident.server; /** * Marker type for server sample entrypoint code. @@ -7,7 +7,5 @@ */ public final class ServerSampleMarker { - private ServerSampleMarker() { - } + private ServerSampleMarker() {} } - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/agentcard/ServerSampleAgentCardBuilder.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/agentcard/ServerSampleAgentCardBuilder.java new file mode 100644 index 00000000..62b74c98 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/agentcard/ServerSampleAgentCardBuilder.java @@ -0,0 +1,63 @@ +package net.openan.a2at.sample.subscribe_incident.server.agentcard; + +import java.util.List; +import java.util.Map; + +/** + * Builds sample-owned AgentCard payloads for the server demo. + * + * @since 2026-05 + */ +public final class ServerSampleAgentCardBuilder { + static final String TASK_T_EXTENSION_URI_NL = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/NL/v1"; + + static final String NOTIFICATION_T_EXTENSION_URI_NL = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/NL/v1"; + + private ServerSampleAgentCardBuilder() {} + + public static Map buildAgentCard(String host, int port) { + return Map.of( + "name", "SPN Domain Agent", + "description", "SPN Domain 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", "Incident-Subscription", + "name", "Incident reporting", + "description", "Mock incident reporting sample skill", + "tags", List.of("incident", "reporting"))), + "capabilities", + Map.of( + "streaming", true, + "pushNotifications", false, + "extensions", + List.of( + Map.of( + "uri", + TASK_T_EXTENSION_URI_NL, + "description", + "Extension of structured prompt Task-T requests."), + Map.of( + "uri", + NOTIFICATION_T_EXTENSION_URI_NL, + "description", + "Extension of structured prompt Notification-T requests."))), + "supportedInterfaces", + List.of(Map.of( + "protocolBinding", "HTTP+JSON", + "protocolVersion", "1.0", + "url", "http://" + host + ":" + port))); + } + + public static Map buildRegistrationPayload(String host, int port) { + return Map.of("agentCards", List.of(buildAgentCard(host, port))); + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/PromptComplianceChecker.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/PromptComplianceChecker.java similarity index 83% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/PromptComplianceChecker.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/PromptComplianceChecker.java index f322ed38..81343e76 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/PromptComplianceChecker.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/PromptComplianceChecker.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.flow; +package net.openan.a2at.sample.subscribe_incident.server.flow; import net.openan.a2at.sdk.server.model.PromptComplianceResult; @@ -12,5 +12,3 @@ public interface PromptComplianceChecker { PromptComplianceResult checkTaskPrompt(String processedPromptText); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerFlowInterruptedException.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerFlowInterruptedException.java new file mode 100644 index 00000000..81e03f06 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerFlowInterruptedException.java @@ -0,0 +1,13 @@ +package net.openan.a2at.sample.subscribe_incident.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/server/flow/ServerSampleAgentExecutor.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleAgentExecutor.java similarity index 85% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleAgentExecutor.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleAgentExecutor.java index e2faa2c4..c2cdeb7f 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleAgentExecutor.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleAgentExecutor.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.flow; +package net.openan.a2at.sample.subscribe_incident.server.flow; import java.util.Map; import java.util.function.Consumer; @@ -35,12 +35,7 @@ public ServerSampleAgentExecutor( @Override public void execute(RequestContext requestContext, AgentEmitter agentEmitter) throws A2AError { ServerSampleFlow.executeServerFlow( - requestContext, - promptChecker, - agentEmitter, - sleepController, - incidentArtifactData, - logSink); + requestContext, promptChecker, agentEmitter, sleepController, incidentArtifactData, logSink); } @Override @@ -48,5 +43,3 @@ public void cancel(RequestContext requestContext, AgentEmitter agentEmitter) thr agentEmitter.cancel(); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleFlow.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleFlow.java similarity index 82% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleFlow.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleFlow.java index 246bcb46..632db86b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/flow/ServerSampleFlow.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSampleFlow.java @@ -1,11 +1,11 @@ -package net.openan.a2at.sample.server.flow; +package net.openan.a2at.sample.subscribe_incident.server.flow; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.function.Consumer; -import net.openan.a2at.sample.shared.logging.SampleLoggingFormatter; -import net.openan.a2at.sample.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.logging.SampleLoggingFormatter; import net.openan.a2at.sdk.server.model.PromptComplianceFailure; import net.openan.a2at.sdk.server.model.PromptComplianceResult; import org.a2aproject.sdk.server.ServerCallContext; @@ -21,17 +21,19 @@ * @since 2026-05 */ public final class ServerSampleFlow { + static final String NOTIFICATION_T_EXTENSION_URI_NL = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/NL/v1"; + static final String NOTIFICATION_T_EXTENSION_URI = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; - private static final long ARTIFACT_SEND_INTERVAL_SECONDS = 60L; + private static final long ARTIFACT_SEND_INTERVAL_SECONDS = 10L; private static final String SUBMITTED_MESSAGE = "Subscription accepted, starting Incident reporting task"; private static final String WORKING_MESSAGE = "Incident reporting task in progress"; - private ServerSampleFlow() { - } + private ServerSampleFlow() {} public static void executeServerFlow( RequestContext requestContext, @@ -68,9 +70,7 @@ public static void executeServerFlow( emit(logSink, SampleLoggingFormatter.formatStageLog("server", "task-status", "TASK_STATE_SUBMITTED")); if (!complianceResult.success()) { agentEmitter.reject(buildStatusMessage( - contextId, - taskId, - "Prompt validation failed: " + failureMessage(complianceResult.failure()))); + contextId, taskId, "Prompt validation failed: " + failureMessage(complianceResult.failure()))); emit(logSink, SampleLoggingFormatter.formatStageLog("server", "task-status", "TASK_STATE_REJECTED")); return; } @@ -93,7 +93,8 @@ public static void executeServerFlow( } catch (ServerFlowInterruptedException exception) { throw exception; } catch (RuntimeException exception) { - agentEmitter.fail(buildStatusMessage(contextId, taskId, "Mock incident stream failed: " + exception.getMessage())); + agentEmitter.fail( + buildStatusMessage(contextId, taskId, "Mock incident stream failed: " + exception.getMessage())); emit(logSink, SampleLoggingFormatter.formatStageLog("server", "task-status", "TASK_STATE_FAILED")); } } @@ -108,7 +109,9 @@ private static void requireNotificationExtension(ServerCallContext callContext) Map headers = extractHeaders(callContext); String modernValue = headers.get("A2A-Extensions"); String legacyValue = headers.get("X-A2A-Extensions"); - if (!NOTIFICATION_T_EXTENSION_URI.equals(modernValue) && !NOTIFICATION_T_EXTENSION_URI.equals(legacyValue)) { + String extensionValue = modernValue != null ? modernValue : legacyValue; + if (!NOTIFICATION_T_EXTENSION_URI_NL.equals(extensionValue) + && !NOTIFICATION_T_EXTENSION_URI.equals(extensionValue)) { throw new ValueErrorException("a2a client extensions is not exist."); } } @@ -118,7 +121,11 @@ private static String extractPromptText(RequestContext requestContext) { if (message == null || message.metadata() == null) { throw new ValueErrorException("Expected message metadata for Notification-T prompt"); } - return stringValue(message.metadata().get(NOTIFICATION_T_EXTENSION_URI)); + String promptText = stringValue(message.metadata().get(NOTIFICATION_T_EXTENSION_URI_NL)); + if (promptText.isEmpty()) { + promptText = stringValue(message.metadata().get(NOTIFICATION_T_EXTENSION_URI)); + } + return promptText; } @SuppressWarnings("unchecked") @@ -148,5 +155,3 @@ private static String failureMessage(PromptComplianceFailure failure) { return failure == null ? "unknown error" : failure.message(); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSleepController.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSleepController.java new file mode 100644 index 00000000..fea63496 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/flow/ServerSleepController.java @@ -0,0 +1,12 @@ +package net.openan.a2at.sample.subscribe_incident.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/subscribe_incident/server/http/EmbeddedA2AHttpServer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/EmbeddedA2AHttpServer.java new file mode 100644 index 00000000..33c0c077 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/EmbeddedA2AHttpServer.java @@ -0,0 +1,341 @@ +package net.openan.a2at.sample.subscribe_incident.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/subscribe_incident/server/http/ServerSampleApplication.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/ServerSampleApplication.java new file mode 100644 index 00000000..fb6a10e4 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/http/ServerSampleApplication.java @@ -0,0 +1,11 @@ +package net.openan.a2at.sample.subscribe_incident.server.http; + +import java.util.Map; +import net.openan.a2at.sample.subscribe_incident.server.flow.PromptComplianceChecker; + +/** + * Minimal sample-owned application object for the publishable server sample. + * + * @since 2026-05 + */ +public record ServerSampleApplication(Map agentCard, PromptComplianceChecker promptChecker) {} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/package-info.java new file mode 100644 index 00000000..f4b1caaf --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/package-info.java @@ -0,0 +1,6 @@ +/** + * Server-side sample entrypoints and orchestration for the publishable a2a-t sample module. + * + * @since 2026-05 + */ +package net.openan.a2at.sample.subscribe_incident.server; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryClient.java new file mode 100644 index 00000000..7737a5ce --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryClient.java @@ -0,0 +1,131 @@ +package net.openan.a2at.sample.subscribe_incident.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.subscribe_incident.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/subscribe_incident/server/registry/ServerRegistryResponse.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryResponse.java new file mode 100644 index 00000000..3f0f97b8 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryResponse.java @@ -0,0 +1,10 @@ +package net.openan.a2at.sample.subscribe_incident.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/subscribe_incident/server/registry/ServerRegistryTransport.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryTransport.java new file mode 100644 index 00000000..85ad36d9 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/registry/ServerRegistryTransport.java @@ -0,0 +1,14 @@ +package net.openan.a2at.sample.subscribe_incident.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/subscribe_incident/server/runtime/A2AJavaServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/A2AJavaServerRuntime.java new file mode 100644 index 00000000..8fc8df9a --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/A2AJavaServerRuntime.java @@ -0,0 +1,11 @@ +package net.openan.a2at.sample.subscribe_incident.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/server/runtime/DefaultSampleServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java similarity index 92% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/DefaultSampleServerRuntime.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java index a27d4690..5e14ab76 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/DefaultSampleServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntime.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.runtime; +package net.openan.a2at.sample.subscribe_incident.server.runtime; import java.nio.file.Path; import java.util.List; @@ -11,14 +11,14 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Consumer; -import net.openan.a2at.sample.server.agentcard.ServerSampleAgentCardBuilder; -import net.openan.a2at.sample.server.flow.PromptComplianceChecker; -import net.openan.a2at.sample.server.flow.ServerFlowInterruptedException; -import net.openan.a2at.sample.server.flow.ServerSampleAgentExecutor; -import net.openan.a2at.sample.server.registry.ServerRegistryClient; -import net.openan.a2at.sample.shared.env.SampleEnvironmentPathResolver; -import net.openan.a2at.sample.shared.error.ValueErrorException; -import net.openan.a2at.sample.shared.registry.RegistryAgentCardMapper; +import net.openan.a2at.sample.subscribe_incident.server.agentcard.ServerSampleAgentCardBuilder; +import net.openan.a2at.sample.subscribe_incident.server.flow.PromptComplianceChecker; +import net.openan.a2at.sample.subscribe_incident.server.flow.ServerFlowInterruptedException; +import net.openan.a2at.sample.subscribe_incident.server.flow.ServerSampleAgentExecutor; +import net.openan.a2at.sample.subscribe_incident.server.registry.ServerRegistryClient; +import net.openan.a2at.sample.subscribe_incident.shared.env.SampleEnvironmentPathResolver; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.registry.RegistryAgentCardMapper; import net.openan.a2at.sdk.server.A2ATServer; import org.a2aproject.sdk.server.agentexecution.AgentExecutor; import org.a2aproject.sdk.server.events.InMemoryQueueManager; @@ -128,7 +128,8 @@ public Map registerAgentCard(Map registrationPay } public static Path resolveDefaultEnvPath() { - Path sampleEnvDir = Path.of("a2a-t-sample", "src", "main", "resources", "sample", "server"); + Path sampleEnvDir = + Path.of("a2a-t-sample", "src", "main", "resources", "sample", "subscribe-incident", "server"); return SampleEnvironmentPathResolver.resolve(sampleEnvDir, "server.env", "server.env"); } diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/SampleServerRuntime.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntime.java similarity index 78% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/SampleServerRuntime.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntime.java index e1a9fa7c..a5e95648 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/server/runtime/SampleServerRuntime.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntime.java @@ -1,8 +1,8 @@ -package net.openan.a2at.sample.server.runtime; +package net.openan.a2at.sample.subscribe_incident.server.runtime; import java.nio.file.Path; import java.util.Map; -import net.openan.a2at.sample.server.flow.PromptComplianceChecker; +import net.openan.a2at.sample.subscribe_incident.server.flow.PromptComplianceChecker; /** * Runtime assembly abstraction for the server sample bootstrap flow. @@ -21,5 +21,3 @@ public interface SampleServerRuntime { Map registerAgentCard(Map registrationPayload, Path envPath); } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntimeFactory.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntimeFactory.java new file mode 100644 index 00000000..030911e1 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/SampleServerRuntimeFactory.java @@ -0,0 +1,14 @@ +package net.openan.a2at.sample.subscribe_incident.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/subscribe_incident/server/runtime/ServerBind.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBind.java new file mode 100644 index 00000000..73b25430 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBind.java @@ -0,0 +1,8 @@ +package net.openan.a2at.sample.subscribe_incident.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/subscribe_incident/server/runtime/ServerBootstrapResult.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBootstrapResult.java new file mode 100644 index 00000000..63d58b5a --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/server/runtime/ServerBootstrapResult.java @@ -0,0 +1,11 @@ +package net.openan.a2at.sample.subscribe_incident.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/shared/SharedSampleMarker.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/SharedSampleMarker.java similarity index 56% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/SharedSampleMarker.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/SharedSampleMarker.java index c87a2693..36e44ec3 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/SharedSampleMarker.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/SharedSampleMarker.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.shared; +package net.openan.a2at.sample.subscribe_incident.shared; /** * Marker type for shared sample support code. @@ -7,7 +7,5 @@ */ public final class SharedSampleMarker { - private SharedSampleMarker() { - } + private SharedSampleMarker() {} } - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/AgentEndpointCache.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointCache.java similarity index 76% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/AgentEndpointCache.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointCache.java index 470bc9ae..ad7a8d0b 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/endpoint/AgentEndpointCache.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointCache.java @@ -1,6 +1,6 @@ -package net.openan.a2at.sample.shared.endpoint; +package net.openan.a2at.sample.subscribe_incident.shared.endpoint; -import net.openan.a2at.sample.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; /** * Keeps the current resolved endpoint for the active sample run. @@ -21,5 +21,3 @@ public ResolvedAgentEndpoint getCurrent() { return current; } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointResolver.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointResolver.java new file mode 100644 index 00000000..ea2eda19 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/AgentEndpointResolver.java @@ -0,0 +1,53 @@ +package net.openan.a2at.sample.subscribe_incident.shared.endpoint; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.subscribe_incident.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/subscribe_incident/shared/endpoint/ResolvedAgentEndpoint.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/ResolvedAgentEndpoint.java new file mode 100644 index 00000000..a6206d01 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/endpoint/ResolvedAgentEndpoint.java @@ -0,0 +1,8 @@ +package net.openan.a2at.sample.subscribe_incident.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/subscribe_incident/shared/env/SampleEnvironmentPathResolver.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/env/SampleEnvironmentPathResolver.java new file mode 100644 index 00000000..f584e6df --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/env/SampleEnvironmentPathResolver.java @@ -0,0 +1,27 @@ +package net.openan.a2at.sample.subscribe_incident.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/subscribe_incident/shared/error/ValueErrorException.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/error/ValueErrorException.java new file mode 100644 index 00000000..5dabb9ea --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/error/ValueErrorException.java @@ -0,0 +1,17 @@ +package net.openan.a2at.sample.subscribe_incident.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/shared/logging/SampleLoggingFormatter.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java similarity index 88% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/logging/SampleLoggingFormatter.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java index c8cf7865..e5452053 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/logging/SampleLoggingFormatter.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/logging/SampleLoggingFormatter.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.shared.logging; +package net.openan.a2at.sample.subscribe_incident.shared.logging; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; @@ -30,8 +30,7 @@ public final class SampleLoggingFormatter { private static final List SECRET_KEYWORDS = List.of("api_key", "authorization", "token", "secret", "password"); - private SampleLoggingFormatter() { - } + private SampleLoggingFormatter() {} public static String formatStageLog(String actor, String stage, String detail) { return timestamp() + " [" + actor + "] " + stage + ": " + detail; @@ -102,12 +101,16 @@ private static Object normalizePayload(Object value, String keyName) throws IOEx private static boolean isSecretKey(String keyName) { String normalizedKeyName = keyName.toLowerCase(Locale.ROOT); for (String keyword : SECRET_KEYWORDS) { - if (normalizedKeyName.contains(keyword)) { + if (normalizedKeyName.equals(keyword)) { return true; } } - return false; + if (normalizedKeyName.endsWith("_key") + || normalizedKeyName.endsWith("_secret") + || normalizedKeyName.endsWith("_password")) { + return true; + } + // Avoid masking the harmless max_tokens field while still masking api_token etc. + return normalizedKeyName.endsWith("_token") && !normalizedKeyName.endsWith("max_tokens"); } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleLoggingLLMClient.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleLoggingLLMClient.java new file mode 100644 index 00000000..c04982c8 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/mock/SampleLoggingLLMClient.java @@ -0,0 +1,90 @@ +package net.openan.a2at.sample.subscribe_incident.shared.mock; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import net.openan.a2at.sample.subscribe_incident.shared.logging.SampleLoggingFormatter; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.llm.providers.OpenAIClient; + +/** + * Logging-aware LLM client wrapper used by the sample. + * + *

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

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

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

      The mock fallback is needed when no usable real LLM configuration is present: a missing, blank, or placeholder + * {@code A2AT_LLM_API_KEY}, or a missing/blank {@code A2AT_LLM_MODEL} or {@code A2AT_LLM_BASE_URL}. When the + * environment file lacks any of these, the SDK's {@code 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 + */ + 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; + } + try { + Field clientsField = LLMClientFactory.class.getDeclaredField("CLIENTS"); + clientsField.setAccessible(true); + @SuppressWarnings("unchecked") + 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); + } + } + + /** + * Returns whether the logging-aware LLM client wrapper has been installed. + * + * @return {@code true} if the logger was installed + */ + public static boolean isLoggerInstalled() { + return mockInstalled; + } + + /** + * Resolves the environment file path that should be used for the current run, using the default mock resource root + * {@code sample/subscribe-incident/mock_responses}. + * + * @param envPath sample environment file path + * @return the environment file path to use for this run + * @see #resolveEnvPath(Path, String) + */ + public static Path resolveEnvPath(Path envPath) { + return resolveEnvPath(envPath, "sample/subscribe-incident/mock_responses"); + } + + /** + * Resolves the environment file path that should be used for the current run. + * + *

      When a mock fallback is needed, the mock client is configured with the supplied classpath resource root (and + * the language read from the env file), then a temporary environment file is written next to the original file (so + * relative prompt-resource paths keep resolving correctly) with a non-empty placeholder API key. Otherwise the + * original path is returned unchanged. + * + * @param envPath sample environment file path + * @param mockResourceRoot classpath root for mock LLM response JSON files + * @return the environment file path to use for this run + */ + public static Path resolveEnvPath(Path envPath, String mockResourceRoot) { + if (envPath == null || !isMockNeeded(envPath)) { + return envPath; + } + Map env = readEnv(envPath); + String language = env.get(LANGUAGE_VAR); + SampleMockLLMClient.configure(mockResourceRoot, language); + return writeMockEnvFile(envPath); + } + + private static String stripBom(String text) { + return text != null && text.startsWith("\uFEFF") ? text.substring(1) : text; + } + + private static Map readEnv(Path envPath) { + if (envPath == null || !Files.exists(envPath)) { + return Map.of(); + } + try { + Map values = new LinkedHashMap<>(); + for (String rawLine : Files.readAllLines(envPath)) { + String line = stripBom(rawLine.trim()); + if (line.isEmpty() || line.startsWith("#") || !line.contains("=")) { + continue; + } + int separatorIndex = line.indexOf('='); + String key = line.substring(0, separatorIndex).trim(); + String value = line.substring(separatorIndex + 1).trim(); + if (!key.isEmpty() && !value.isEmpty()) { + values.put(key, value); + } + } + return values; + } catch (IOException exception) { + throw new ValueErrorException("Failed to read env file for mock LLM fallback: " + envPath, exception); + } + } + + private static Path writeMockEnvFile(Path originalEnvPath) { + Map values = new LinkedHashMap<>(readEnv(originalEnvPath)); + values.putIfAbsent(PROVIDER_VAR, OPENAI_PROVIDER); + values.putIfAbsent(MODEL_VAR, MOCK_MODEL); + values.put(API_KEY_VAR, MOCK_API_KEY); + values.putIfAbsent(BASE_URL_VAR, MOCK_BASE_URL); + Path parent = originalEnvPath.toAbsolutePath().getParent(); + try { + Path tempFile = Files.createTempFile(parent, "a2a-t-sample-mock-", ".env"); + StringBuilder content = new StringBuilder(); + content.append("# Mock LLM environment for sample runs without a real API key.\n"); + for (Map.Entry entry : values.entrySet()) { + content.append(entry.getKey()) + .append('=') + .append(entry.getValue()) + .append('\n'); + } + Files.writeString(tempFile, content.toString(), StandardCharsets.UTF_8); + return tempFile; + } catch (IOException exception) { + throw new ValueErrorException("Failed to create mock LLM environment file", exception); + } + } +} diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/package-info.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/package-info.java new file mode 100644 index 00000000..2aabbcda --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/package-info.java @@ -0,0 +1,6 @@ +/** + * Shared support code for the publishable a2a-t sample module. + * + * @since 2026-05 + */ +package net.openan.a2at.sample.subscribe_incident.shared; diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/registry/RegistryAgentCardMapper.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/registry/RegistryAgentCardMapper.java new file mode 100644 index 00000000..18ff63d9 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/registry/RegistryAgentCardMapper.java @@ -0,0 +1,133 @@ +package net.openan.a2at.sample.subscribe_incident.shared.registry; + +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; + +/** + * 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/shared/scenario/SampleScenarioLoader.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/scenario/SampleScenarioLoader.java similarity index 71% rename from a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/scenario/SampleScenarioLoader.java rename to a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/scenario/SampleScenarioLoader.java index 85491ba7..ba4bffa7 100644 --- a/a2a-t-sample/src/main/java/net/openan/a2at/sample/shared/scenario/SampleScenarioLoader.java +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/scenario/SampleScenarioLoader.java @@ -1,11 +1,11 @@ -package net.openan.a2at.sample.shared.scenario; +package net.openan.a2at.sample.subscribe_incident.shared.scenario; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.InputStream; import java.util.Map; -import net.openan.a2at.sample.shared.error.ValueErrorException; +import net.openan.a2at.sample.subscribe_incident.shared.error.ValueErrorException; /** * Loads static sample scenarios from classpath resources. @@ -15,20 +15,17 @@ public final class SampleScenarioLoader { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private SampleScenarioLoader() { - } + private SampleScenarioLoader() {} public static Map loadClasspathScenario(String resourcePath) { - try (InputStream inputStream = SampleScenarioLoader.class.getClassLoader().getResourceAsStream(resourcePath)) { + try (InputStream inputStream = + SampleScenarioLoader.class.getClassLoader().getResourceAsStream(resourcePath)) { if (inputStream == null) { throw new ValueErrorException("Scenario resource not found: " + resourcePath); } - return OBJECT_MAPPER.readValue(inputStream, new TypeReference<>() { - }); + return OBJECT_MAPPER.readValue(inputStream, new TypeReference<>() {}); } catch (IOException exception) { throw new ValueErrorException("Unable to load scenario resource: " + resourcePath); } } } - - diff --git a/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/stream/SampleStreamEventNormalizer.java b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/stream/SampleStreamEventNormalizer.java new file mode 100644 index 00000000..7ec64032 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/subscribe_incident/shared/stream/SampleStreamEventNormalizer.java @@ -0,0 +1,45 @@ +package net.openan.a2at.sample.subscribe_incident.shared.stream; + +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.subscribe_incident.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/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..0b5ce1f4 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluator.java @@ -0,0 +1,206 @@ +package net.openan.a2at.sample.task_t; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Field-level and sample-level accuracy scoring for the {@code Task-T} demo. + * + *

      Each {@link SampleScore} scores one sample: every expected ground-truth field is compared with the corresponding + * value extracted by {@code A2ATServer#validateTaskPromptAndDataFilling}. {@link Summary} aggregates the scores per + * client API both as a field hit rate (matched expected fields over total expected fields) and as a sample pass rate + * (samples whose expected fields all hit over total samples). + * + *

      Fields are matched in one of two modes: structured identifier fields ({@code accessPort}, {@code bizScenario}, + * {@code faultTime}, {@code eventSerialNo}) must be exactly equal after whitespace-and-case normalization — a + * truncated prefix such as {@code P781} must not score a hit against the full port identifier; the free-text complaint + * detail ({@code faultDetail}) is matched by containment, since the extracted description naturally carries more words + * than the expected keyword. The mode is selected per slot in {@link #scoreFields}. + */ +final class TaskTAccuracyEvaluator { + + /** How one expected value is compared with the extracted value. */ + enum MatchMode { + /** Structured identifiers: equal after normalization, no containment. */ + EXACT, + /** Free-text fields: equal or mutually contained after normalization. */ + CONTAINS + } + + 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, slot); + String detail = matched + ? "" + : "expected=" + expected + ", extracted=" + (extractedText == null ? "" : extractedText); + scores.add(new FieldScore(slot, expected, extractedText, matched, detail)); + }); + return scores; + } + + /** + * Picks the match mode for one slot: the free-text complaint detail and fault time are matched by containment, + * every other structured field is matched exactly. + * + *

      Fault time uses containment because the LLM may return the time in various formats (ISO with/without timezone, + * Chinese natural language, etc.) while the expected value is a canonical subset. + * + * @param slot expected slot name keyed by the server field names + * @return containment mode for the complaint detail and fault time, exact mode otherwise + */ + private static MatchMode matchMode(String slot) { + if (TaskTPrivateLineComplaintSamples.SERVER_DETAIL.equals(slot)) { + return MatchMode.CONTAINS; + } + if (TaskTPrivateLineComplaintSamples.SERVER_TIME.equals(slot)) { + return MatchMode.CONTAINS; + } + return MatchMode.EXACT; + } + + /** + * Hit rule for one slot. For structured fields, equal after normalization. For the fault detail, one contains the + * other. For the fault time, the numeric month-day digits are extracted and compared, so both ISO + * ({@code 2026-05-22T14:00:00Z}) and Chinese ({@code 5月22号下午两点多}) formats match the same canonical expected value. + * + * @param extracted extracted value, may be {@code null} + * @param expected ground-truth value + * @param slot server-side slot name + * @return {@code true} when both are non-blank and the normalized values match + */ + static boolean matches(String extracted, String expected, String slot) { + String normalizedExtracted = normalize(extracted); + String normalizedExpected = normalize(expected); + if (normalizedExtracted.isEmpty() || normalizedExpected.isEmpty()) { + return false; + } + if (TaskTPrivateLineComplaintSamples.SERVER_TIME.equals(slot)) { + return matchDatePart(normalizedExtracted, normalizedExpected); + } + MatchMode mode = matchMode(slot); + return switch (mode) { + case EXACT -> normalizedExtracted.equals(normalizedExpected); + case CONTAINS -> normalizedExtracted.contains(normalizedExpected) + || normalizedExpected.contains(normalizedExtracted); + }; + } + + /** + * Extracts the numeric month-day part from a date string and compares. Handles both ISO + * ({@code 2026-05-22t14:00:00z}) and Chinese ({@code 5月22号下午两点多}) formats by extracting the month and day digits. + */ + private static boolean matchDatePart(String extracted, String expected) { + String extractedMd = extractMonthDay(extracted); + String expectedMd = extractMonthDay(expected); + return !extractedMd.isEmpty() && !expectedMd.isEmpty() && extractedMd.equals(expectedMd); + } + + /** Extracts month-day digits from a date string, e.g. "2026-05-22" → "0522", "5月22号" → "0522". */ + private static String extractMonthDay(String value) { + // Try Chinese format: "5月22号" or "2026年5月11号" + Matcher chineseMatcher = CHINESE_DATE_PATTERN.matcher(value); + if (chineseMatcher.find()) { + return String.format( + "%02d%02d", Integer.parseInt(chineseMatcher.group(1)), Integer.parseInt(chineseMatcher.group(2))); + } + // Try ISO format: "2026-05-22" in "2026-05-22t14:00:00z" + Matcher isoMatcher = ISO_DATE_PATTERN.matcher(value); + if (isoMatcher.find()) { + return isoMatcher.group(2) + isoMatcher.group(3); + } + return ""; + } + + private static final Pattern CHINESE_DATE_PATTERN = Pattern.compile("(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*[号日]"); + private static final Pattern ISO_DATE_PATTERN = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})"); + + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("\\s+", "").toLowerCase(Locale.ROOT); + } + + /** + * 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); + } +} 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..1d03cf6b --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTDemoMain.java @@ -0,0 +1,420 @@ +package net.openan.a2at.sample.task_t; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.A2ATError; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.server.A2ATServer; + +/** + * End-to-end accuracy demo for the {@code Task-T} extension. + * + *

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

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

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

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

      The second optional argument selects which case to run: {@code text} (case one), {@code data} (case two), + * {@code rejection} (case three) or {@code all} (default); when a single case is selected only its summary is printed. + */ +public final class TaskTDemoMain { + + private static final String DEFAULT_ENV_FILE = "client.env"; + + private static final String BUNDLED_ENV_FILE = Path.of( + "a2a-t-sample", "src", "main", "resources", "sample", "task_t", "client.env") + .toString(); + + private static final List REQUIRED_LLM_KEYS = + List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY"); + + private static final String CASE_TEXT = "text"; + + private static final String CASE_DATA = "data"; + + private static final String CASE_REJECTION = "rejection"; + + private static final String CASE_ALL = "all"; + + private TaskTDemoMain() {} + + /** + * Runs one or all client-API cases against the built-in private-line complaint diagnosis samples. + * + * @param args first optional argument is the {@code .env} file path; second optional argument selects the case + * ({@code text}, {@code data}, {@code rejection} or {@code all}) + */ + public static void main(String[] args) { + Path envPath = resolveEnvPath(args); + String caseSelection = resolveCaseSelection(args); + println("Task-T 准确率验证样例,env: " + envPath.toAbsolutePath() + (Files.exists(envPath) ? "" : " (不存在,请先配置)")); + println("模板: " + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + println("用例: " + caseLabel(caseSelection)); + println(); + + A2ATClient client = new A2ATClient(envPath); + A2ATServer server = new A2ATServer(envPath); + + if (CASE_TEXT.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { + List textScores = runTextCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromText", textScores)); + println(); + } + if (CASE_DATA.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { + List dataScores = runDataWithSchemaCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + printSummary(TaskTAccuracyEvaluator.summarize("generateTaskPromptFromDataWithSchema", dataScores)); + println(); + } + if (CASE_REJECTION.equals(caseSelection) || CASE_ALL.equals(caseSelection)) { + RejectionSummary rejectionSummary = runRejectionCase(client, server); + println(); + System.out.println("══════════════════════════════════════════════════════"); + printRejectionSummary(rejectionSummary); + println(); + } + } + + private static String resolveCaseSelection(String[] args) { + if (args.length > 1) { + String selection = args[1].toLowerCase(Locale.ROOT); + if (CASE_TEXT.equals(selection) + || CASE_DATA.equals(selection) + || CASE_REJECTION.equals(selection) + || CASE_ALL.equals(selection)) { + return selection; + } + System.err.println("未知用例参数: " + args[1] + ",可选值: text | data | rejection | all,将运行全部用例"); + return CASE_ALL; + } + return CASE_ALL; + } + + private static String caseLabel(String caseSelection) { + return switch (caseSelection) { + case CASE_TEXT -> "用例一:generateTaskPromptFromText"; + case CASE_DATA -> "用例二:generateTaskPromptFromDataWithSchema"; + case CASE_REJECTION -> "用例三:缺少关键槽位拒绝用例"; + default -> "全部(用例一 + 用例二 + 用例三)"; + }; + } + + private static List runTextCase(A2ATClient client, A2ATServer server) { + println("================ 用例一:generateTaskPromptFromText ================"); + List samples = TaskTPrivateLineComplaintSamples.textSamples(); + List scores = new ArrayList<>(); + for (int i = 0; i < samples.size(); i++) { + TaskTSample sample = samples.get(i); + printSampleHeader("用例一", i, samples.size(), sample.name()); + println("[输入] 自然语言文本:"); + println(sample.text()); + println(); + try { + MetadataContent metadata = + client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + printGeneratedMetadata(metadata); + TaskTAccuracyEvaluator.SampleScore score = validateAndScore(server, sample, metadata, "用例一"); + scores.add(score); + } catch (PromptGenerationException exception) { + printFailure("生成失败", exception); + scores.add(new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of())); + } + println(); + } + return scores; + } + + private static List runDataWithSchemaCase( + A2ATClient client, A2ATServer server) { + println("========== 用例二:generateTaskPromptFromDataWithSchema =========="); + List samples = TaskTPrivateLineComplaintSamples.dataWithSchemaSamples(); + List scores = new ArrayList<>(); + for (int i = 0; i < samples.size(); i++) { + TaskTSample sample = samples.get(i); + printSampleHeader("用例二", i, samples.size(), sample.name()); + println("[输入] 数据(data):"); + println(pretty(sample.data())); + println("[输入] 语义schema(schema):"); + println(pretty(sample.semanticsSchema())); + println(); + try { + MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + printGeneratedMetadata(metadata); + TaskTAccuracyEvaluator.SampleScore score = validateAndScore(server, sample, metadata, "用例二"); + scores.add(score); + } catch (PromptGenerationException exception) { + printFailure("生成失败", exception); + scores.add(new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of())); + } + println(); + } + return scores; + } + + /** + * Runs the rejection case over {@link TaskTPrivateLineComplaintSamples#rejectionSamples()}: each sample + * deliberately omits one or more key slots (or carries an invalid slot value), so the server-side semantic + * validation is expected to fail with {@code negotiation.semantic_rejected}. The sample set mixes a text variant + * (fed to {@code generateTaskPromptFromText}) and a data variant (fed to + * {@code generateTaskPromptFromDataWithSchema}); a sample is counted as "server-rejected" only when the validation + * throws {@link ContentValidationException} — the structured slot errors are printed per sample — while a failure + * already at prompt generation counts as a client-side block. The run aggregates a server-side validation rejection + * rate (server-rejected over total) side by side with an overall interception rate (server-rejected plus + * client-blocked over total), so the validation point itself can be read independently of client-side blocks. + */ + private static RejectionSummary runRejectionCase(A2ATClient client, A2ATServer server) { + println("========== 用例三:缺少关键槽位拒绝用例(期望 rejected) =========="); + List samples = TaskTPrivateLineComplaintSamples.rejectionSamples(); + int serverRejected = 0; + int clientBlocked = 0; + int unexpectedlyPassed = 0; + for (int i = 0; i < samples.size(); i++) { + TaskTRejectionSample sample = samples.get(i); + printSampleHeader("用例三", i, samples.size(), sample.name()); + try { + MetadataContent metadata; + if (sample.data() != null) { + println("[输入] 数据(data,客户端 key,缺关键槽位):"); + println(pretty(sample.data())); + println("[输入] 语义schema(schema):"); + println(pretty(sample.semanticsSchema())); + println(); + println("[生成] generateTaskPromptFromDataWithSchema:"); + metadata = client.generateTaskPromptFromDataWithSchema( + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + } else { + println("[输入] 自然语言文本 (缺少关键槽位):"); + println(sample.text()); + println(); + println("[生成] generateTaskPromptFromText:"); + metadata = client.generateTaskPromptFromText( + sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + } + System.out.println(" templateUri : " + metadata.templateUri()); + System.out.println(" promptText : " + metadata.promptText()); + System.out.println(" extensionUri : " + metadata.extensionUri()); + println(); + Map extracted = server.validateTaskPromptAndDataFilling( + metadata.promptText(), + sample.validationSchema(), + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) + .data(); + unexpectedlyPassed++; + println("[意外通过] 应被拒绝却校验通过,提取参数:"); + println(pretty(extracted)); + } catch (ContentValidationException exception) { + serverRejected++; + System.out.println("[正确拒绝] [" + exception.getCode() + "] " + exception.getMessage()); + printValidationErrors(exception); + } catch (PromptGenerationException exception) { + clientBlocked++; + System.out.println("[生成阶段拦截] 客户端生成即失败,[" + exception.getCode() + "] " + exception.getMessage()); + } + println(); + } + return new RejectionSummary(samples.size(), serverRejected, clientBlocked, unexpectedlyPassed); + } + + private static void printValidationErrors(ContentValidationException exception) { + List errors = exception.errors(); + if (errors == null || errors.isEmpty()) { + println(" (无结构化错误明细)"); + return; + } + for (SlotValidationError error : errors) { + println(" slot=" + error.slotName() + " code=" + error.code() + " message=" + error.message()); + } + } + + private static TaskTAccuracyEvaluator.SampleScore validateAndScore( + A2ATServer server, TaskTSample sample, MetadataContent metadata, String caseLabel) { + try { + Map extracted = server.validateTaskPromptAndDataFilling( + metadata.promptText(), + sample.validationSchema(), + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) + .data(); + println("[服务端] " + caseLabel + " validateTaskPromptAndDataFilling 通过,提取参数:"); + println(pretty(extracted)); + List fields = TaskTAccuracyEvaluator.scoreFields(sample, extracted); + printFieldScores(fields); + return new TaskTAccuracyEvaluator.SampleScore(sample.name(), true, fields); + } catch (ContentValidationException exception) { + printFailure("校验不通过", exception); + return new TaskTAccuracyEvaluator.SampleScore(sample.name(), false, List.of()); + } + } + + private static void printSampleHeader(String caseLabel, int index, int total, String name) { + System.out.println( + "──────────── " + caseLabel + " 样本[" + (index + 1) + "/" + total + "] " + name + " ────────────"); + } + + private static void printGeneratedMetadata(MetadataContent metadata) { + println("[生成] MetadataContent:"); + println(" extensionUri: " + metadata.extensionUri()); + println(" templateUri : " + metadata.templateUri()); + println(" promptText : "); + println(metadata.promptText()); + } + + private static void printFieldScores(List fields) { + println("[比对] 字段级命中 (命中规则: 归一化后相同或互相包含):"); + for (TaskTAccuracyEvaluator.FieldScore field : fields) { + String mark = field.matched() ? "✔ 命中" : "✘ 未命中"; + String detail = field.matched() ? "" : " " + field.detail(); + println(" " + mark + " " + field.slot() + detail); + } + } + + private static void printFailure(String stage, A2ATError error) { + println("[失败] " + stage + ": [" + error.getCode() + "] " + error.getMessage()); + if (error instanceof ContentValidationException validationException) { + List errors = validationException.errors(); + if (!errors.isEmpty()) { + println(" 详细原因:"); + for (SlotValidationError slotError : errors) { + println(" slot: " + slotError.slotName() + + ", code: " + slotError.code() + + ", message: " + slotError.message()); + } + } + Map partialParams = validationException.params(); + if (!partialParams.isEmpty()) { + println(" LLM部分提取参数:"); + println(pretty(partialParams)); + } + } + Throwable cause = error.getCause(); + if (cause != null) { + println(" 根因异常: " + cause.getClass().getName() + ": " + cause.getMessage()); + } + } + + private static void printSummary(TaskTAccuracyEvaluator.Summary summary) { + System.out.println("──────────── 汇总: " + summary.api() + " ────────────"); + println(" 样本数: " + summary.sampleCount() + + " 通过样本: " + summary.passedSamples() + + " 字段命中: " + summary.matchedFields() + "/" + summary.expectedFields()); + println(" 字段准确率: " + percent(summary.fieldAccuracyPercent()) + " 样本通过率: " + + percent(summary.samplePassRatePercent())); + } + + private static void printRejectionSummary(RejectionSummary summary) { + System.out.println("──────────── 汇总: 缺少关键槽位拒绝用例 ────────────"); + println(" 样本数: " + summary.total() + + " 服务端拒绝: " + summary.serverRejected() + + " 生成阶段拦截: " + summary.clientBlocked() + + " 意外通过: " + summary.unexpectedlyPassed()); + println(" 服务端校验点拒绝率: " + percent(rejectionRate(summary.serverRejected(), summary.total())) + + " 总拦截率(服务端+客户端): " + + percent(rejectionRate(summary.serverRejected() + summary.clientBlocked(), summary.total()))); + } + + /** Rate guarded against a zero sample set. */ + private static double rejectionRate(int numerator, int total) { + return total == 0 ? 0d : numerator * 100.0 / total; + } + + /** + * Aggregated result of the rejection case over the key-slot-missing negative samples: how many were intercepted + * (either server-side semantic rejection or client-side generation failure) vs unexpectedly passed. + */ + private record RejectionSummary(int total, int serverRejected, int clientBlocked, int unexpectedlyPassed) {} + + private static String percent(double value) { + return String.format(Locale.ROOT, "%.2f%%", value); + } + + private static String pretty(Map map) { + if (map == null) { + return " "; + } + String body = map.entrySet().stream() + .map(entry -> entry.getKey() + " = " + entry.getValue()) + .collect(Collectors.joining("\n ", " ", "")); + return body; + } + + private static void println(Object message) { + System.out.println(message); + } + + private static void println() { + System.out.println(); + } + + private static Path resolveEnvPath(String[] args) { + if (args.length > 0) { + return Path.of(args[0]); + } + Path cwdEnv = Path.of(DEFAULT_ENV_FILE); + if (Files.exists(cwdEnv) && hasRequiredLlmKeys(cwdEnv)) { + return cwdEnv; + } + return Path.of(BUNDLED_ENV_FILE); + } + + private static boolean hasRequiredLlmKeys(Path envFile) { + Map entries = new HashMap<>(); + try { + for (String line : Files.readAllLines(envFile)) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int separator = trimmed.indexOf('='); + if (separator <= 0) { + continue; + } + entries.put( + trimmed.substring(0, separator).trim(), + trimmed.substring(separator + 1).trim()); + } + } catch (IOException exception) { + return false; + } + for (String key : REQUIRED_LLM_KEYS) { + if (entries.getOrDefault(key, "").isBlank()) { + return false; + } + } + return true; + } +} 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..f0a9ef14 --- /dev/null +++ b/a2a-t-sample/src/main/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamples.java @@ -0,0 +1,233 @@ +package net.openan.a2at.sample.task_t; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Built-in evaluation samples for the {@code Task-T/network-layer/private-line-complaint/v1} template (transfer / + * private-line business complaint diagnosis), loaded from the packaged JSON file + * {@code a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json}. + * + *

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

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

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

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

      Each sample passes business fields under the client keys in {@code data} plus a client-side semantics + * {@code schema}; the server-side validation schema re-extracts the parameters under the server keys, so a + * run exercises the cross-key adaptation of the SDK. + * + * @return immutable data sample list + */ + public static List dataWithSchemaSamples() { + return LOADED.samples().stream().filter(sample -> sample.data() != null).toList(); + } + + /** + * Rejection samples that deliberately omit one or more key slots. + * + *

      Two variants mirror the two client APIs under test: {@code text} samples are colloquial natural-language + * complaints missing a required fact (access port, complaint scenario), while {@code data} samples are structured + * inputs under the client field names that omit a required field or carry a complaint category outside the contract + * {@code enum}. Time and serial are optional slots and are deliberately not used as rejection criteria — + * their absence is exercised by the positive samples {@code text-optional-slots-missing} / + * {@code data-optional-slots-missing} instead. The server-side semantic validation is expected to reject these + * samples with {@code negotiation.semantic_rejected}; they are scored separately from the accuracy samples and are + * never fed into the field-accuracy evaluation. + * + * @return immutable rejection sample list + */ + public static List rejectionSamples() { + return LOADED.rejections(); + } + + private static Loaded load() { + try (InputStream in = TaskTPrivateLineComplaintSamples.class.getResourceAsStream("/" + SAMPLE_RESOURCE)) { + if (in == null) { + throw new IllegalStateException("sample resource not found on classpath: " + SAMPLE_RESOURCE); + } + JsonNode root = OBJECT_MAPPER.readTree(in); + Map semanticsSchema = toObjectMap(root.get("semanticsSchema")); + Map validationSchema = toObjectMap(root.get("validationSchema")); + List samples = new ArrayList<>(); + for (JsonNode node : root.get("samples")) { + String name = node.get("name").asText(); + JsonNode text = node.get("text"); + JsonNode data = node.get("data"); + Map expected = toStringMap(node.get("expected")); + if (data == null) { + samples.add(new TaskTSample(name, text.asText(), null, null, expected, validationSchema)); + } else { + samples.add(new TaskTSample( + name, null, toObjectMap(data), semanticsSchema, expected, validationSchema)); + } + } + List rejections = new ArrayList<>(); + JsonNode rejectionNodes = root.get("rejectionSamples"); + if (rejectionNodes != null) { + for (JsonNode node : rejectionNodes) { + String name = node.get("name").asText(); + JsonNode data = node.get("data"); + if (data == null) { + rejections.add(new TaskTRejectionSample( + name, node.get("text").asText(), null, null, validationSchema)); + } else { + rejections.add(new TaskTRejectionSample( + name, null, toObjectMap(data), semanticsSchema, validationSchema)); + } + } + } + return new Loaded(List.copyOf(samples), List.copyOf(rejections)); + } catch (IOException error) { + throw new IllegalStateException("failed to load task-t samples from " + SAMPLE_RESOURCE, error); + } + } + + /** Loaded evaluation sample set split into accuracy samples and rejection samples. */ + private record Loaded(List samples, List rejections) {} + + private static Map toObjectMap(JsonNode node) { + return OBJECT_MAPPER.convertValue(node, new TypeReference>() {}); + } + + private static Map toStringMap(JsonNode node) { + return OBJECT_MAPPER.convertValue(node, new TypeReference>() {}); + } +} + +/** + * One private-line complaint diagnosis evaluation sample. + * + * @param name sample identifier shown in the accuracy report + * @param text natural-language input for the FromText case; {@code null} when the sample only targets the data case + * @param data structured business-field input for the FromDataWithSchema case, keyed by the client field names; + * {@code null} when the sample only targets the text case + * @param semanticsSchema client-side field-semantics schema, keyed by the client field names; may be {@code null} when + * {@code data} is {@code null} + * @param expectedParams ground truth business values, keyed by the server field names (the keys read out of + * {@code validateTaskPromptAndDataFilling}); these are the fields actually scored + * @param validationSchema caller-provided JSON parameter schema passed to {@code validateTaskPromptAndDataFilling}, + * keyed by the server field names + * @since 2026-08 + */ +record TaskTSample( + String name, + String text, + Map data, + Map semanticsSchema, + Map expectedParams, + Map validationSchema) {} + +/** + * One negative sample for the rejection check: content that deliberately omits one or more key slots (or carries an + * invalid slot value) and is expected to be rejected by the server-side semantic validation. Two variants exist, + * mirroring the two client APIs under test: + * + *

        + *
      • {@code text} variant feeds {@code A2ATClient#generateTaskPromptFromText} with colloquial natural language; + *
      • {@code data} variant feeds {@code A2ATClient#generateTaskPromptFromDataWithSchema} with structured input keyed + * by the client field names plus the client-side semantics schema. + *
      + * + * @param name sample identifier shown in the rejection report + * @param text natural-language input for the FromText variant; {@code null} when the sample only targets the data case + * @param data structured business-field input for the FromDataWithSchema variant, keyed by the client field + * names; {@code null} when the sample only targets the text case + * @param semanticsSchema client-side field-semantics schema, keyed by the client field names; may be {@code null} when + * {@code data} is {@code null} + * @param validationSchema caller-provided JSON parameter schema passed to {@code validateTaskPromptAndDataFilling} + * @since 2026-08 + */ +record TaskTRejectionSample( + String name, + String text, + Map data, + Map semanticsSchema, + Map validationSchema) {} 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..4d3cabd8 --- /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; 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 new file mode 100644 index 00000000..ed201c4e --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/README.md @@ -0,0 +1,159 @@ +# Authorization-T Demo 运行与验收指南 + +本目录是 Authorization-T 演示(`net.openan.a2at.sample.authz_policy.AuthzSampleMain`)的资源根。 +本文说明如何执行 demo、如何分析正确率报告,以及安全与判定约束。 + +## 0. 冒烟留档 + +最近一次冒烟 15 例的完整原始输入输出(用户入参 → 客户端生成 prompt → 客户端提参理由 → 服务端校验结果与理由 → 服务端提参结果):[smoke-io-transcript.md](smoke-io-transcript.md) + +## 1. 环境准备 + +### 1.1 构建 + +在仓库根目录执行(首次或代码/资源变更后): + +``` +mvn -pl a2a-t-sample -am package -DskipTests -q +``` + +产物:`a2a-t-sample/target/authz.javaargs.txt`(classpath + main 类启动参数,由 pom 生成)。 + +### 1.2 准备 env 文件 + +demo 启动需要一个包含 LLM 网关凭据的 env 文件(参照本目录 `authz.env` 模板): + +``` +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=classpath +A2AT_LLM_PROVIDER=openai +A2AT_LLM_MODEL=<模型名> +A2AT_LLM_BASE_URL=<网关地址> +A2AT_LLM_API_KEY=<凭据> # 绝不能提交、打印或写入任何产物 +``` + +模板另含两个可忽略键(`A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 留空、 +`A2AT_NEGOTIATION_STATE_STORE_TYPE` 默认 in_memory);启动仅校验 provider/model/api_key 三键。 + +**安全约束**: +- env 文件命中 `.gitignore` 的 `*.env` 规则,永不提交;真实凭据只放在本地 env 文件中 +- 本目录的 `authz.env` 是空 key 模板(仓库白名单放行),真实凭据版本写到仓库外或 gitignored 路径 +- 凭据从安全来源(本地配置/密钥管理)获取,不得出现在命令行历史、日志、报告或提交内容中 + +### 1.3 运行 + +以下命令均在仓库根目录执行,`` 替换为上一步准备的 env 文件(单行命令,任何 shell 通用): + +``` +# 冒烟(默认题集,本目录 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 例,评分基线用) +java -Dauthz.scenarios=sample/authz-policy/scenarios-100.json -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 @a2a-t-sample/target/authz.javaargs.txt + +# 留出集(holdout 100 例,防过拟合验证用) +java -Dauthz.scenarios=sample/authz-policy/scenarios-holdout-100.json -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8 @a2a-t-sample/target/authz.javaargs.txt +``` + +env 路径也可省略:省略时按"当前目录 authz.env → classpath 模板"顺序回退(模板无凭据,仅占位)。 +退出码:0 = 全部匹配;非零 = 存在失败(注意:冒烟 13/15 属验收通过但退出码仍为非零,需按报告判读)。 +控制台逐例输出 `[Client]/[Server]/[判定]` 三段式报告。 + +### 1.4 题集结构与变异 schema 组 + +全量集按组前缀划分:`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 双在场且互不同构)。 + +变异 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 中给出,服务端提参与客户端渲染据此归一。 + +## 2. 报告分析 + +### 2.1 报告位置与结构 + +JSON 报告写入 `eval-results/authz-demo/authz-report-.json` +(可用 `-Dauthz.outdir=<目录>` 覆盖输出目录)。核心字段: + +``` +summary: { total, match, mismatch } # 总分 +scenarios[]: + label # 用例标识(组前缀 a/b1-b5/c1-c6,变体带 -varname 等维度后缀) + match # 本例是否通过(布尔) + assertions: + client_prompt # 客户端 promptText 比对:true/false=DRIFT(漂移,不计分)/null=客户端失败路径 + server_outcome # 服务端 outcome 断言(null=未到服务端) + server_params # 服务端 params 子集断言 + 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}(失败归因用,如 infra.internal_error) +``` + +**断言语义(重要)**:`match` 仅由客户端 outcome+错误码、服务端 outcome+错误码、params 子集匹配决定; +`client_prompt` 只记录漂移(DRIFT),不影响 match——措辞级漂移由人工判读 `prompt_text` 归类。 + +### 2.2 分析脚本核心 + +以下 python 代码读最新一份报告,输出总分、分组正确率、DRIFT/warnings 概览与失败清单。 +保存为文件执行(`python -X utf8 <文件名>.py`)或粘贴到任意 python 环境: + +```python +import json, glob, os, sys + +sys.stdout.reconfigure(encoding="utf-8") +files = glob.glob("eval-results/authz-demo/authz-report-*.json") +if not files: + raise SystemExit("no report found under eval-results/authz-demo/") +path = max(files, key=os.path.getmtime) +r = json.load(open(path, encoding="utf-8")) +cs = r["scenarios"] + +groups = {} +for c in cs: + g = groups.setdefault(c["label"].split("-")[0], {"n": 0, "ok": 0}) + g["n"] += 1 + g["ok"] += c["match"] + +print("报告: %s" % path) +print("总分: %s/%s" % (r["summary"]["match"], r["summary"]["total"])) +print("分组: " + " ".join("%s=%s/%s" % (k, v["ok"], v["n"]) for k, v in sorted(groups.items()))) + +drift = [c["label"] for c in cs if (c.get("assertions") or {}).get("client_prompt") is False] +warn = [c["label"] for c in cs if c.get("warnings")] +print("DRIFT(不计分): %d 例 %s" % (len(drift), drift)) +print("warnings(不计分): %d 例 %s" % (len(warn), warn)) + +for c in cs: + if not c["match"]: + a = c.get("assertions") or {} + why = ( + "client outcome/slot_errors" if a.get("client_prompt") is None + else "server outcome" if a.get("server_outcome") is False + else "server params" if a.get("server_params") is False + else "slot code" + ) + print(" FAIL %s [%s] actual=%s errors=%s" % ( + c["label"], why, c.get("actual_outcome"), + [(e["slot_name"], e["code"]) for e in c.get("actual_slot_errors") or []])) +``` + +失败断言定位口诀:`client_prompt is null` → 客户端阶段失败(对照期望 outcome); +`server_outcome is False` → 服务端判定与期望不符(对照 actual_slot_errors 的 slot+code); +`server_params is False` → 提参结果与期望 params 子集不匹配。 + +## 3. 验收标准 + +| 模式 | 题集 | 通过线 | +|---|---|---| +| 冒烟 | `scenarios.json`(15 例) | ≥ 13/15,且无 `infra.internal_error` 类异常 | +| 全量 | `scenarios-100.json`(100 例) | 记录基线分数,失败例逐例归因(LLM 概率性 / 判据缺口 / 题集期望错误) | +| 留出集 | `scenarios-holdout-100.json`(100 例) | 与全量分差 ≤5 视为无过拟合;分差扩大时优先排查 prompt/约束对原题集的过拟合 | + +## 4. 迭代约定 + +- 改动 prompt(slot_extraction / content_validation)、slot.json、param-schema 或题集后,先跑冒烟,再同时跑全量与留出集 +- 题集期望修订原则:期望必须与"服务端可见资源(param-schema + template + content_validation)"中的判据一致; + 不得为通过用例而在资源中发明仅服务于探针的判据 +- 每轮全量结果保留 JSON 报告于 eval-results/(gitignored),跨轮对比用 2.2 的分析脚本核心 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..c984f486 --- /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=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 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 new file mode 100644 index 00000000..6174f08c --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/param-schema.json @@ -0,0 +1,44 @@ +{ + "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": "授权策略的生效时间区间" + } + } + } + } + } +} 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 new file mode 100644 index 00000000..b06c5e45 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-100.json @@ -0,0 +1,5458 @@ +{ + "scenarios": [ + { + "label": "a-data-starve-01", + "entry": "from_data_with_schema", + "input": { + "data": { + "scene": "校园专网", + "actionType": "业务抢通" + }, + "schema": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + } + } + } + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "a-data-starve-01-varname", + "entry": "from_data_with_schema", + "input": { + "data": { + "bizScene": "校园专网", + "handling": "业务抢通" + }, + "schema": { + "type": "object", + "properties": { + "bizScene": { + "type": "string", + "description": "动网操作所支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作对应的处置类型" + } + } + } + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "a-nl-unsupported-01", + "entry": "from_text", + "input": { + "text": "推送所有校园专网的授权策略" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "a-nl-unsupported-03", + "entry": "from_text", + "input": { + "text": "冻结所有跟业务投诉诊断相关的授权" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "a-nl-neg-01", + "entry": "from_text", + "input": { + "text": "不要新增校园专网的授权策略,我还没准备好" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "a-nl-neg-03", + "entry": "from_text", + "input": { + "text": "除了政企专网,其他场景的授权都删掉" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是除政企专网之外的其他场景\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + } + ], + "params": {} + } + } + }, + { + "label": "a-nl-neg-04", + "entry": "from_text", + "input": { + "text": "暂时不需要修改应急通信的授权有效期" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "a-nl-meta-01", + "entry": "from_text", + "input": { + "text": "如何新增校园专网的授权策略?请说明步骤" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "a-nl-meta-02", + "entry": "from_text", + "input": { + "text": "授权策略的修改流程是什么" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "a-data-unsupported-01", + "entry": "from_text", + "input": { + "text": "推送授权策略的操作描述" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "b1-nl-missing-01", + "entry": "from_text", + "input": { + "text": "加个校园专网的授权,处置用业务抢通,做个隧道调优,有效期先不填后面补" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + } + ] + } + } + }, + { + "label": "b1-nl-missing-01-varfields", + "entry": "from_text", + "input": { + "text": "加个校园专网的授权,处置用业务抢通,做个隧道调优,有效期先不填后面补" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + } + ] + } + }, + "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": "策略备注说明" + } + } + } + } + } + } + }, + { + "label": "b1-data-missing-06", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "新增授权策略", + "scene": "视频保障", + "actionType": "业务恢复", + "operationName": "隧道调优" + }, + "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. 业务场景是视频保障,处置类型是业务恢复,操作名称是隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + } + ] + } + } + }, + { + "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": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "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. 业务场景是视频保障,处置类型是业务恢复,操作名称是隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略明细", + "code": "content.entry_field_missing" + } + ] + } + }, + "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": "b1-data-nolist-01", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "删除授权策略" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "删除授权策略" + ], + "description": "授权策略的操作类型" + } + } + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.param_missing" + } + ] + } + } + }, + { + "label": "b1-data-nolist-01-varname", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "删除授权策略" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "删除授权策略" + ], + "description": "授权策略的操作类型" + } + } + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policies", + "code": "content.param_missing" + } + ] + } + }, + "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": "b1-data-missing-11", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "新增授权策略", + "scene": "海外漫游", + "actionType": "业务抢通", + "operationName": "信号增强" + }, + "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或永久生效" + } + }, + "required": [ + "operationType", + "scene", + "actionType", + "operationName", + "validityPeriod" + ] + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是信号增强\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + } + ], + "params": {} + } + } + }, + { + "label": "b1-data-missing-11-varfields", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "新增授权策略", + "scene": "海外漫游", + "actionType": "业务抢通", + "operationName": "信号增强" + }, + "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或永久生效" + } + }, + "required": [ + "operationType", + "scene", + "actionType", + "operationName", + "validityPeriod" + ] + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是海外漫游,处置类型是业务抢通,操作名称是信号增强\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + } + ], + "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-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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.value_not_allowed" + } + ] + } + }, + "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": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 content.value_not_allowed" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + } + } + } + } + } + } + }, + { + "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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.format_error" + } + ] + } + } + }, + { + "label": "b2-nl-format-06-varreq", + "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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.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": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "有效期须为 ISO 8601 时间戳,如 2026-01-01T00:00:00Z" + } + } + } + } + } + } + }, + { + "label": "b2-nl-format-07", + "entry": "from_text", + "input": { + "text": "加个授权,场景视频保障,处置业务恢复,操作路由调优,有效期到2030-12-31为止" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是业务恢复,操作名称是路由调优,有效期是2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.format_error" + } + ] + } + } + }, + { + "label": "b2-nl-format-07-varname", + "entry": "from_text", + "input": { + "text": "加个授权,场景视频保障,处置业务恢复,操作路由调优,有效期到2030-12-31为止" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是视频保障,处置类型是业务恢复,操作名称是路由调优,有效期是2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略明细", + "code": "content.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": "b2-data-format-06", + "entry": "from_data_with_schema", + "input": { + "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. 业务场景是海外漫游,处置类型是业务抢通,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.format_error" + } + ] + } + } + }, + { + "label": "b2-data-format-06-varfields", + "entry": "from_data_with_schema", + "input": { + "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. 业务场景是海外漫游,处置类型是业务抢通,操作名称是频段调整,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.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": "b3-nl-invalid-mod-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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.value_not_allowed" + } + ], + "params": { + "operationType": "修改授权策略", + "policyList": [ + { + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": "校园专网" + } + ] + } + } + } + }, + { + "label": "b3-nl-invalid-mod-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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略明细", + "code": "content.value_not_allowed" + } + ], + "params": { + "operationType": "修改授权策略", + "策略明细": [ + { + "标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": "校园专网" + } + ] + } + } + }, + "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": "b3-nl-conflict-01", + "entry": "from_text", + "input": { + "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,有效期是2027-01-01~2030-12-31,有效期是2028-01-01~2029-06-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.semantic_conflict" + } + ] + } + } + }, + { + "label": "b3-nl-conflict-01-varname", + "entry": "from_text", + "input": { + "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,有效期是2027-01-01~2030-12-31,有效期是2028-01-01~2029-06-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略明细", + "code": "content.semantic_conflict" + } + ] + } + }, + "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-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": { + "operationType": "删除授权策略", + "policyList": [ + { + "policyId": "ABC-123", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": null + } + ] + } + } + } + }, + { + "label": "b4-nl-bad-id-01-varname", + "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": { + "operationType": "删除授权策略", + "policies": [ + { + "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": "b4-nl-bad-id-02", + "entry": "from_text", + "input": { + "text": "修改12345的授权有效期到2027-01-01~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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "修改授权策略", + "policyList": [ + { + "policyId": "12345", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "2027-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "b4-nl-bad-id-02-varflat", + "entry": "from_text", + "input": { + "text": "修改12345的授权有效期到2027-01-01~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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "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": "b4-nl-bad-id-02-varuuid", + "entry": "from_text", + "input": { + "text": "修改12345的授权有效期到2027-01-01~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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.format_error" + } + ], + "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": "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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + } + ] + } + } + }, + { + "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. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "newValidityPeriod", + "code": "content.entry_field_missing" + } + ] + } + }, + "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": "b5-nl-invalid-07", + "entry": "from_text", + "input": { + "text": "新增授权,业务场景校园专网,处置类型业务抢通,操作名称隧道调优,有效期永久生效,到2030年12月31日为止" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效,到2030-12-31为止\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.semantic_conflict" + } + ] + } + } + }, + { + "label": "c1-nl-add-01", + "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": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "c1-nl-add-01-varname", + "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": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policies": [ + { + "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-04", + "entry": "from_text", + "input": { + "text": "新增授权:业务场景=应急通信,处置类型=业务调整,操作名称=隧道调优,有效期=2027-03-15~2029-09-30" + }, + "expected": { + "client": { + "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": "2027-03-15~2029-09-30" + } + ] + } + } + } + }, + { + "label": "c1-nl-add-04-varreq", + "entry": "from_text", + "input": { + "text": "新增授权:业务场景=应急通信,处置类型=业务调整,操作名称=隧道调优,有效期=2027-03-15~2029-09-30" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是应急通信,处置类型是业务调整,操作名称是隧道调优,有效期是2027-03-15~2029-09-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.format_error" + } + ], + "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": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "生效截止日期,单值,格式 YYYY-MM-DD" + } + } + } + } + } + } + }, + { + "label": "c1-nl-add-11", + "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": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "工业视觉质检", + "actionType": "割接", + "operationName": "光功率调衡", + "validityPeriod": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "c1-nl-add-11-varfields", + "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": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "工业视觉质检", + "actionType": "割接", + "operationName": "光功率调衡", + "validityPeriod": "2026-01-01~2030-12-31", + "remark": 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": "授权策略的生效时间区间" + }, + "remark": { + "type": "string", + "description": "策略备注,可选,未提供时为null" + } + } + } + } + } + } + }, + { + "label": "c1-nl-add-07", + "entry": "from_text", + "input": { + "text": "有效期2026-06-01~2030-06-18,操作名称频段调整,处置类型业务恢复,业务场景医疗专线,新增一个授权策略" + }, + "expected": { + "client": { + "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": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" + } + ] + } + } + } + }, + { + "label": "c1-nl-add-07-varflat", + "entry": "from_text", + "input": { + "text": "有效期2026-06-01~2030-06-18,操作名称频段调整,处置类型业务恢复,业务场景医疗专线,新增一个授权策略" + }, + "expected": { + "client": { + "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": [ + { + "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": "c1-data-add-01", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "新增授权策略", + "scene": "车联网", + "actionType": "业务优化", + "operationName": "网络优化", + "validityPeriod": "2026-01-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. 业务场景是车联网,处置类型是业务优化,操作名称是网络优化,有效期是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": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "c1-data-add-01-varsch", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "新增授权策略", + "bizScene": "车联网", + "handling": "业务优化", + "opName": "网络优化", + "timeRange": "2026-01-01~2030-12-31" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "bizScene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "handling": { + "type": "string", + "description": "动网操作的处置类型" + }, + "opName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "timeRange": { + "type": "string", + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } + } + } + }, + "expected": { + "client": { + "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": [ + { + "policyId": null, + "scene": "车联网", + "actionType": "业务优化", + "operationName": "网络优化", + "validityPeriod": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "c1-nl-add-12", + "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": "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. 业务场景是物联网专线,处置类型是倒换,操作名称是流量重定向,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "策略明细": [ + { + "标识": 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": "c1-data-add-10", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "新增授权策略", + "scene": "无人机回传", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2026年8月1日到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. 业务场景是无人机回传,处置类型是业务调整,操作名称是隧道调优,有效期是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": "c1-data-add-10-varreq", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "新增授权策略", + "scene": "无人机回传", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2026年8月1日到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. 业务场景是无人机回传,处置类型是业务调整,操作名称是隧道调优,有效期是2026-08-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.format_error" + } + ], + "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": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "须为 ISO 8601 时间戳,如 2026-01-01T00:00:00Z" + } + } + } + } + } + } + }, + { + "label": "c2-nl-multi-01", + "entry": "from_text", + "input": { + "text": "新增两条授权策略:第一条,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31;第二条,业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期从2026-06-01到2030-06-18" + }, + "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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" + }, + { + "policyId": null, + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" + } + ] + } + } + } + }, + { + "label": "c2-nl-multi-01-varflat", + "entry": "from_text", + "input": { + "text": "新增两条授权策略:第一条,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31;第二条,业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期从2026-06-01到2030-06-18" + }, + "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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policies": [ + { + "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": "c2-nl-multi-03", + "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": { + "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" + } + ] + } + } + } + }, + { + "label": "c2-nl-multi-03-varfields", + "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": { + "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": "c2-nl-multi-04", + "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. 业务场景是应急通信,处置类型是业务抢通,操作名称是链路切换,有效期是永久生效\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": "新增授权策略", + "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. 业务场景是应急通信,处置类型是业务抢通,操作名称是链路切换,有效期是永久生效\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": "链路切换", + "生效时段": "永久生效" + }, + { + "标识": 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": "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": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "policyCount": { + "type": "integer", + "description": "要新增的策略数量" + }, + "details": { + "type": "array", + "description": "策略详情列表", + "items": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "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": { + "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" + } + ] + } + } + } + }, + { + "label": "c2-data-multi-07-dual", + "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": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "policyCount": { + "type": "integer", + "description": "要新增的策略数量" + }, + "details": { + "type": "array", + "description": "策略详情列表", + "items": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "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": { + "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": "授权策略的生效时间区间" + } + } + } + } + } + } + }, + { + "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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "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": "c3-nl-mod-06-varfields", + "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", + "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": "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": { + "operationType": "修改授权策略", + "policyList": [ + { + "policyId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "2026-06-01~2030-06-18" + } + ] + } + } + } + }, + { + "label": "c3-nl-mod-02-varreq", + "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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.format_error" + } + ], + "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": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "修改后的生效时间,须为 ISO 8601 时间戳,如 2026-01-01T00:00:00Z" + } + } + } + } + } + } + }, + { + "label": "c3-nl-mod-07", + "entry": "from_text", + "input": { + "text": "修改a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的授权,有效期改成2026-09-01~2031-08-31" + }, + "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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "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" + } + ] + } + } + } + }, + { + "label": "c3-nl-mod-07-varname", + "entry": "from_text", + "input": { + "text": "修改a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的授权,有效期改成2026-09-01~2031-08-31" + }, + "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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "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": "c3-data-mod-01", + "entry": "from_data_with_schema", + "input": { + "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. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210,有效期是2027-01-01~2029-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "修改授权策略", + "policyList": [ + { + "policyId": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": "2027-01-01~2029-12-31" + } + ] + } + } + } + }, + { + "label": "c3-data-mod-01-dual", + "entry": "from_data_with_schema", + "input": { + "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. 策略标识是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": "c4-data-del-02", + "entry": "from_data_with_schema", + "input": { + "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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + } + ], + "params": {} + } + } + }, + { + "label": "c4-data-del-02-varsch", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "删除授权策略", + "bizScene": "容量规划" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "bizScene": { + "type": "string", + "description": "动网操作支撑的业务场景" + } + } + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是容量规划\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + } + ], + "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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + } + ], + "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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + } + ], + "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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + } + ], + "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": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policies", + "code": "content.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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.format_error" + } + ], + "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": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "生效截止日期,单值,格式 YYYY-MM-DD" + } + } + } + } + } + } + }, + { + "label": "c6-data-mixed-04", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "查询授权策略", + "scene": "业务投诉诊断" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "scene": { + "type": "string", + "description": "按业务场景查询" + }, + "actionType": { + "type": "string", + "description": "按处置类型查询" + }, + "operationName": { + "type": "string", + "description": "按操作名称查询" + } + } + } + }, + "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": null, + "operationName": null, + "validityPeriod": null + } + ] + } + } + } + }, + { + "label": "c6-data-mixed-04-varsch", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "查询授权策略", + "bizScene": "业务投诉诊断" + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "bizScene": { + "type": "string", + "description": "按业务场景查询" + } + } + } + }, + "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": null, + "operationName": null, + "validityPeriod": null + } + ] + } + } + } + }, + { + "label": "c6-data-mixed-06", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "新增授权策略", + "details": [ + { + "scene": "政企专网", + "actionType": "业务优化", + "operationName": "路由调整", + "validityPeriod": "永久生效" + }, + { + "scene": "应急通信", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" + } + ] + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "details": { + "type": "array", + "description": "策略详情列表", + "items": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "生效时间区间或永久生效" + } + } + } + } + } + } + }, + "expected": { + "client": { + "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": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "政企专网", + "actionType": "业务优化", + "operationName": "路由调整", + "validityPeriod": "永久生效" + }, + { + "policyId": null, + "scene": "应急通信", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "c6-data-mixed-06-varname", + "entry": "from_data_with_schema", + "input": { + "data": { + "operationType": "新增授权策略", + "details": [ + { + "scene": "政企专网", + "actionType": "业务优化", + "operationName": "路由调整", + "validityPeriod": "永久生效" + }, + { + "scene": "应急通信", + "actionType": "业务调整", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" + } + ] + }, + "schema": { + "type": "object", + "properties": { + "operationType": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "details": { + "type": "array", + "description": "策略详情列表", + "items": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "生效时间区间或永久生效" + } + } + } + } + } + } + }, + "expected": { + "client": { + "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": "路由调整", + "生效时段": "永久生效" + }, + { + "标识": null, + "scene": "应急通信", + "actionType": "业务调整", + "operationName": "隧道调优", + "生效时段": "2026-01-01~2030-12-31" + } + ] + } + } + }, + "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-12", + "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": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "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或永久生效" + } + } + } + } + } + } + }, + "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": { + "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" + } + ] + } + } + } + }, + { + "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": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "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或永久生效" + } + } + } + } + } + } + }, + "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": { + "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": "授权策略的生效时间区间" + } + } + } + } + } + } + }, + { + "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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + }, + { + "slot_name": "policyList", + "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 new file mode 100644 index 00000000..9d402e2d --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios-holdout-100.json @@ -0,0 +1,3715 @@ +{ + "scenarios": [ + { + "label": "h-a-nl-unsupported-01", + "entry": "from_text", + "input": { + "text": "推送所有专线保障的授权策略" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-unsupported-02", + "entry": "from_text", + "input": { + "text": "分享物联监测的授权策略给别人" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-unsupported-03", + "entry": "from_text", + "input": { + "text": "冻结所有跟时延监测相关的授权" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-unsupported-04", + "entry": "from_text", + "input": { + "text": "解锁载波调度的授权策略" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-unsupported-05", + "entry": "from_text", + "input": { + "text": "标记所有已过期的应急广播授权" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-unsupported-06", + "entry": "from_text", + "input": { + "text": "克隆卫星回传的授权策略到远程医疗" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-unsupported-07", + "entry": "from_text", + "input": { + "text": "转移智慧港口的授权策略所有权" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-unsupported-08", + "entry": "from_text", + "input": { + "text": "审计智能电网相关的所有授权策略操作记录" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-neg-01", + "entry": "from_text", + "input": { + "text": "不要新增专线保障的授权策略,我还没准备好" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-neg-02", + "entry": "from_text", + "input": { + "text": "先别删除物联监测的授权,让我再确认一下" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-neg-03", + "entry": "from_text", + "input": { + "text": "除了应急广播,其他场景的授权都删掉" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-neg-04", + "entry": "from_text", + "input": { + "text": "暂时不需要修改卫星回传的授权有效期" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-meta-01", + "entry": "from_text", + "input": { + "text": "如何新增专线保障的授权策略?请说明步骤" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-meta-02", + "entry": "from_text", + "input": { + "text": "授权策略的修改流程是什么" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-meta-03", + "entry": "from_text", + "input": { + "text": "请解释物联监测授权策略的删除规则" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-nl-meta-04", + "entry": "from_text", + "input": { + "text": "查询授权策略需要哪些参数" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-a-data-unsupported-01", + "entry": "from_text", + "input": { + "text": "推送授权策略的操作描述" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "h-b1-nl-missing-01", + "entry": "from_text", + "input": { + "text": "加个专线保障的授权,处置用紧急扩容,做个故障切换,有效期先不填后面补" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是专线保障,处置类型是紧急扩容,操作名称是故障切换\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.entry_field_missing" + } + ] + } + } + }, + { + "label": "h-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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.entry_field_missing" + } + ] + } + } + }, + { + "label": "h-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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.entry_field_missing" + } + ] + } + } + }, + { + "label": "h-b1-nl-missing-04", + "entry": "from_text", + "input": { + "text": "帮我新建一个授权,场景卫星回传,处置类型现场处理,操作名称故障切换,有效期可以不填吗" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是卫星回传,处置类型是现场处理,操作名称是故障切换\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.entry_field_missing" + } + ] + } + } + }, + { + "label": "h-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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.entry_field_missing" + } + ] + } + } + }, + { + "label": "h-b1-data-missing-06", + "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或永久生效" + } + } + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是智慧港口,处置类型是降级服务,操作名称是故障切换\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.entry_field_missing" + } + ] + } + } + }, + { + "label": "h-b1-data-missing-11", + "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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "动网操作的授权策略列表", + "code": "content.entry_field_missing" + } + ], + "params": {} + } + } + }, + { + "label": "h-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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "动网操作的授权策略列表", + "code": "content.entry_field_missing" + } + ], + "params": {} + } + } + }, + { + "label": "h-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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.format_error" + } + ] + } + } + }, + { + "label": "h-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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.format_error" + } + ] + } + } + }, + { + "label": "h-b2-nl-format-07", + "entry": "from_text", + "input": { + "text": "加个授权,场景智慧港口,处置降级服务,操作路由调优,有效期到2030-12-31为止" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是智慧港口,处置类型是降级服务,操作名称是路由调优,有效期是2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.format_error" + } + ] + } + } + }, + { + "label": "h-b2-data-format-06", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "新增授权策略", + "业务场景": "远程医疗", + "处置类型": "紧急扩容", + "操作名称": "流量调度", + "有效期": "2026-13-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或永久生效" + } + } + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是远程医疗,处置类型是紧急扩容,操作名称是流量调度,有效期是2026-13-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.format_error" + } + ] + } + } + }, + { + "label": "h-b2-data-format-07", + "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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.format_error" + } + ] + } + } + }, + { + "label": "h-b2-nl-format-08", + "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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.format_error" + } + ] + } + } + }, + { + "label": "h-b3-nl-invalid-mod-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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.value_not_allowed" + } + ], + "params": { + "操作类型": "修改授权策略", + "策略列表": [ + { + "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "业务场景": "专线保障" + } + ] + } + } + } + }, + { + "label": "h-b3-nl-invalid-mod-02", + "entry": "from_text", + "input": { + "text": "修改a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的授权,处置类型改为紧急扩容" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,处置类型改为紧急扩容\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.value_not_allowed" + } + ], + "params": { + "操作类型": "修改授权策略", + "策略列表": [ + { + "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "处置类型": "紧急扩容" + } + ] + } + } + } + }, + { + "label": "h-b3-nl-invalid-mod-03", + "entry": "from_text", + "input": { + "text": "把f9e8d7c6-b5a4-3210-fedc-ba9876543210的授权操作名称改成故障切换" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210,操作名称改成故障切换\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.value_not_allowed" + } + ], + "params": { + "操作类型": "修改授权策略", + "策略列表": [ + { + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "操作名称": "故障切换" + } + ] + } + } + } + }, + { + "label": "h-b3-nl-invalid-mod-04", + "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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.value_not_allowed" + } + ], + "params": { + "操作类型": "修改授权策略", + "策略列表": [ + { + "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "业务场景": "物联监测" + } + ] + } + } + } + }, + { + "label": "h-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": "h-b4-nl-bad-id-02", + "entry": "from_text", + "input": { + "text": "修改12345的授权有效期到2027-01-01~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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "修改授权策略" + } + } + } + }, + { + "label": "h-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": { + "操作类型": "删除授权策略" + } + } + } + }, + { + "label": "h-b4-nl-bad-id-04", + "entry": "from_text", + "input": { + "text": "修改not-a-uuid@#$这个授权的有效期到永久生效" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是not-a-uuid@#$,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "修改授权策略" + } + } + } + }, + { + "label": "h-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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.entry_field_missing" + } + ] + } + } + }, + { + "label": "h-b5-nl-invalid-07", + "entry": "from_text", + "input": { + "text": "新增授权,业务场景专线保障,处置类型紧急扩容,操作名称故障切换,有效期永久生效,到2030年12月31日为止" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是专线保障,处置类型是紧急扩容,操作名称是故障切换,有效期是永久生效,到2030-12-31为止\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.semantic_conflict" + } + ] + } + } + }, + { + "label": "h-b6-schema-variant-01", + "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": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "专线保障", + "处置类型": "出问题了得马上处理恢复业务", + "操作名称": "流量调度", + "有效期": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "h-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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.value_not_allowed" + } + ] + } + }, + "validate_schema": { + "type": "object", + "properties": { + "操作类型": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "策略列表": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列。参数值中每条条目以编号加'.'开头(如'1.'),条目内各字段以'字段名是值'形式表达并用逗号分隔,多个条目各自独立成行(换行分隔)。该参数未提供时输出空数组 []。各操作类型对每条的字段要求不同:新增条目须包含业务场景、处置类型、操作名称、有效期;删除条目可为策略标识,也可仅为条件字段(业务场景、处置类型、操作名称之一或多个),无需其他字段,不得按新增的必填字段要求检查删除条目。修改授权策略时,每条须包含策略标识与新有效期,缺少新有效期判 content.entry_field_missing。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),带标签时按标签归因字段。", + "items": { + "type": "object", + "properties": { + "策略标识": { + "type": "string", + "description": "策略的UUID唯一标识。UUID 形态的值归入本字段;非 UUID 的裸值按其内容语义归入业务场景/处置类型/操作名称字段。新增类策略无标识时为null;删除场景可按条件字段方式描述(此时策略标识为null)。内容校验阶段不依据标识格式判定存在性" + }, + "业务场景": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "处置类型": { + "type": "string", + "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 content.value_not_allowed" + }, + "操作名称": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "有效期": { + "type": "string", + "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + } + } + } + } + } + } + }, + { + "label": "h-b6-schema-variant-03", + "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": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "物联监测", + "处置类型": "业务恢复", + "操作名称": "故障切换", + "有效期": "2026-01-01~2030-12-31" + } + ] + } + } + }, + "validate_schema": { + "type": "object", + "properties": { + "操作类型": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ], + "description": "授权策略的操作类型" + }, + "策略列表": { + "type": "array", + "description": "动网操作的授权策略列表,按输入中的出现顺序排列。参数值中每条条目以编号加'.'开头(如'1.'),条目内各字段以'字段名是值'形式表达并用逗号分隔,多个条目各自独立成行(换行分隔)。该参数未提供时输出空数组 []。各操作类型对每条的字段要求不同:新增条目须包含业务场景、处置类型、操作名称、有效期;删除条目可为策略标识,也可仅为条件字段(业务场景、处置类型、操作名称之一或多个),无需其他字段,不得按新增的必填字段要求检查删除条目。修改授权策略时,每条须包含策略标识与新有效期,缺少新有效期判 content.entry_field_missing。查询条件须带字段标签,格式为'字段名是值'(如'业务场景是信号覆盖优化'),带标签时按标签归因字段。", + "items": { + "type": "object", + "properties": { + "策略标识": { + "type": "string", + "description": "策略的UUID唯一标识。UUID 形态的值归入本字段;非 UUID 的裸值按其内容语义归入业务场景/处置类型/操作名称字段。新增类策略无标识时为null;删除场景可按条件字段方式描述(此时策略标识为null)。内容校验阶段不依据标识格式判定存在性" + }, + "业务场景": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "处置类型": { + "type": "string", + "description": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 content.value_not_allowed" + }, + "操作名称": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "有效期": { + "type": "string", + "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + } + } + } + } + } + } + }, + { + "label": "h-c1-nl-add-01", + "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": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "专线保障", + "处置类型": "紧急扩容", + "操作名称": "故障切换", + "有效期": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "h-c1-nl-add-04", + "entry": "from_text", + "input": { + "text": "新增授权:业务场景=卫星回传,处置类型=现场处理,操作名称=故障切换,有效期=2027-03-15~2029-09-30" + }, + "expected": { + "client": { + "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": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "卫星回传", + "处置类型": "现场处理", + "操作名称": "故障切换", + "有效期": "2027-03-15~2029-09-30" + } + ] + } + } + } + }, + { + "label": "h-c1-nl-add-05", + "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": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "远程医疗", + "处置类型": "紧急扩容", + "操作名称": "流量调度", + "有效期": "2026-08-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "h-c1-data-add-01", + "entry": "from_data_with_schema", + "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或永久生效" + } + } + } + }, + "expected": { + "client": { + "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" + } + ] + } + } + } + }, + { + "label": "h-c1-nl-add-07", + "entry": "from_text", + "input": { + "text": "有效期2026-06-01~2030-06-18,操作名称流量调度,处置类型降级服务,业务场景物联监测,新增一个授权策略" + }, + "expected": { + "client": { + "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-06-01~2030-06-18" + } + ] + } + } + } + }, + { + "label": "h-c1-data-add-07", + "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或永久生效" + } + } + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是智慧港口,处置类型是降级服务,操作名称是参数核查,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "智慧港口", + "处置类型": "降级服务", + "操作名称": "参数核查", + "有效期": "永久生效" + } + ] + } + } + } + }, + { + "label": "h-c1-data-add-08", + "entry": "from_data_with_schema", + "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或永久生效" + } + } + } + }, + "expected": { + "client": { + "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-06-01~2030-06-18" + } + ] + } + } + } + }, + { + "label": "h-c1-data-add-10", + "entry": "from_data_with_schema", + "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或永久生效" + } + } + } + }, + "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": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "工业互联", + "处置类型": "现场处理", + "操作名称": "故障切换", + "有效期": "2026-08-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "h-c1-nl-add-11", + "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": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "智慧工厂专网", + "处置类型": "重路由收敛", + "操作名称": "载波重配置", + "有效期": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "h-c1-nl-add-12", + "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": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "远程医疗会诊", + "处置类型": "协议降级", + "操作名称": "波长调谐", + "有效期": "2026-08-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "h-c2-nl-multi-01", + "entry": "from_text", + "input": { + "text": "新增两条授权策略:第一条,业务场景是专线保障,处置类型是紧急扩容,操作名称是故障切换,有效期从2026-01-01到2030-12-31;第二条,业务场景是物联监测,处置类型是降级服务,操作名称是流量调度,有效期从2026-06-01到2030-06-18" + }, + "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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "专线保障", + "处置类型": "紧急扩容", + "操作名称": "故障切换", + "有效期": "2026-01-01~2030-12-31" + }, + { + "业务场景": "物联监测", + "处置类型": "降级服务", + "操作名称": "流量调度", + "有效期": "2026-06-01~2030-06-18" + } + ] + } + } + } + }, + { + "label": "h-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": "生效时间区间或永久生效" + } + } + } + } + } + } + }, + "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": "h-c2-nl-multi-03", + "entry": "from_text", + "input": { + "text": "帮我加三条授权:专线保障用紧急扩容做故障切换,永久生效;物联监测用降级服务做流量调度,有效期2026-07-01到2030-07-18;应急广播用优先保障做参数核查,有效期2027-05-15到2029-11-30" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是专线保障,处置类型是紧急扩容,操作名称是故障切换,有效期是永久生效\n2. 业务场景是物联监测,处置类型是降级服务,操作名称是流量调度,有效期是2026-07-01~2030-07-18\n3. 业务场景是应急广播,处置类型是优先保障,操作名称是参数核查,有效期是2027-05-15~2029-11-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "专线保障", + "处置类型": "紧急扩容", + "操作名称": "故障切换", + "有效期": "永久生效" + }, + { + "业务场景": "物联监测", + "处置类型": "降级服务", + "操作名称": "流量调度", + "有效期": "2026-07-01~2030-07-18" + }, + { + "业务场景": "应急广播", + "处置类型": "优先保障", + "操作名称": "参数核查", + "有效期": "2027-05-15~2029-11-30" + } + ] + } + } + } + }, + { + "label": "h-c2-nl-multi-04", + "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. 业务场景是卫星回传,处置类型是现场处理,操作名称是故障切换,有效期是永久生效\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": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "卫星回传", + "处置类型": "现场处理", + "操作名称": "故障切换", + "有效期": "永久生效" + }, + { + "业务场景": "时延监测", + "处置类型": "紧急扩容", + "操作名称": "路由调优", + "有效期": "2026-01-01~2030-12-31" + }, + { + "业务场景": "远程医疗", + "处置类型": "优先保障", + "操作名称": "流量调度", + "有效期": "2027-03-15~2029-09-30" + }, + { + "业务场景": "智慧港口", + "处置类型": "降级服务", + "操作名称": "参数核查", + "有效期": "2026-06-01~2030-06-18" + } + ] + } + } + } + }, + { + "label": "h-c2-data-multi-07", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "新增授权策略", + "策略数量": 3, + "详情": [ + { + "业务场景": "专线保障", + "处置类型": "紧急扩容", + "操作名称": "故障切换", + "有效期": "永久生效" + }, + { + "业务场景": "物联监测", + "处置类型": "降级服务", + "操作名称": "流量调度", + "有效期": "2026-07-01~2030-07-18" + }, + { + "业务场景": "应急广播", + "处置类型": "优先保障", + "操作名称": "参数核查", + "有效期": "2027-05-15~2029-11-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-07-01~2030-07-18\n3. 业务场景是应急广播,处置类型是优先保障,操作名称是参数核查,有效期是2027-05-15~2029-11-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "专线保障", + "处置类型": "紧急扩容", + "操作名称": "故障切换", + "有效期": "永久生效" + }, + { + "业务场景": "物联监测", + "处置类型": "降级服务", + "操作名称": "流量调度", + "有效期": "2026-07-01~2030-07-18" + }, + { + "业务场景": "应急广播", + "处置类型": "优先保障", + "操作名称": "参数核查", + "有效期": "2027-05-15~2029-11-30" + } + ] + } + } + } + }, + { + "label": "h-c2-data-multi-08", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "新增授权策略", + "策略数量": 4, + "详情": [ + { + "业务场景": "卫星回传", + "处置类型": "现场处理", + "操作名称": "故障切换", + "有效期": "永久生效" + }, + { + "业务场景": "时延监测", + "处置类型": "紧急扩容", + "操作名称": "路由调优", + "有效期": "2026年9月1日到2031年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": "生效时间区间或永久生效" + } + } + } + } + } + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是卫星回传,处置类型是现场处理,操作名称是故障切换,有效期是永久生效\n2. 业务场景是时延监测,处置类型是紧急扩容,操作名称是路由调优,有效期是2026-09-01~2031-12-31\n3. 业务场景是远程医疗,处置类型是优先保障,操作名称是流量调度,有效期是2026-01-01~2030-12-31\n4. 业务场景是智慧港口,处置类型是降级服务,操作名称是参数核查,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "卫星回传", + "处置类型": "现场处理", + "操作名称": "故障切换", + "有效期": "永久生效" + }, + { + "业务场景": "时延监测", + "处置类型": "紧急扩容", + "操作名称": "路由调优", + "有效期": "2026-09-01~2031-12-31" + }, + { + "业务场景": "远程医疗", + "处置类型": "优先保障", + "操作名称": "流量调度", + "有效期": "2026-01-01~2030-12-31" + }, + { + "业务场景": "智慧港口", + "处置类型": "降级服务", + "操作名称": "参数核查", + "有效期": "永久生效" + } + ] + } + } + } + }, + { + "label": "h-c2-nl-multi-05", + "entry": "from_text", + "input": { + "text": "帮我加三条授权:专线保障用紧急扩容做天线调整,永久生效;物联监测用故障切换做频谱重耕,有效期2026-07-01到2030-07-18;智慧工厂专网用重路由收敛做载波重配置,有效期2027-05-15到2029-11-30" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是专线保障,处置类型是紧急扩容,操作名称是天线调整,有效期是永久生效\n2. 业务场景是物联监测,处置类型是故障切换,操作名称是频谱重耕,有效期是2026-07-01~2030-07-18\n3. 业务场景是智慧工厂专网,处置类型是重路由收敛,操作名称是载波重配置,有效期是2027-05-15~2029-11-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "专线保障", + "处置类型": "紧急扩容", + "操作名称": "天线调整", + "有效期": "永久生效" + }, + { + "业务场景": "物联监测", + "处置类型": "故障切换", + "操作名称": "频谱重耕", + "有效期": "2026-07-01~2030-07-18" + }, + { + "业务场景": "智慧工厂专网", + "处置类型": "重路由收敛", + "操作名称": "载波重配置", + "有效期": "2027-05-15~2029-11-30" + } + ] + } + } + } + }, + { + "label": "h-c2-nl-multi-06", + "entry": "from_text", + "input": { + "text": "帮我加三条授权:智慧工厂专网用重路由收敛做载波重配置,有效期2026-01-01到2030-12-31;远程医疗会诊用协议降级做波长调谐,永久生效;物联监测用故障切换做频谱重耕,有效期2026-07-01到2030-07-18" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是智慧工厂专网,处置类型是重路由收敛,操作名称是载波重配置,有效期是2026-01-01~2030-12-31\n2. 业务场景是远程医疗会诊,处置类型是协议降级,操作名称是波长调谐,有效期是永久生效\n3. 业务场景是物联监测,处置类型是故障切换,操作名称是频谱重耕,有效期是2026-07-01~2030-07-18\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "智慧工厂专网", + "处置类型": "重路由收敛", + "操作名称": "载波重配置", + "有效期": "2026-01-01~2030-12-31" + }, + { + "业务场景": "远程医疗会诊", + "处置类型": "协议降级", + "操作名称": "波长调谐", + "有效期": "永久生效" + }, + { + "业务场景": "物联监测", + "处置类型": "故障切换", + "操作名称": "频谱重耕", + "有效期": "2026-07-01~2030-07-18" + } + ] + } + } + } + }, + { + "label": "h-c3-nl-mod-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": { + "操作类型": "修改授权策略", + "策略列表": [ + { + "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "有效期": "永久生效" + } + ] + } + } + } + }, + { + "label": "h-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" + } + ] + } + } + } + }, + { + "label": "h-c3-data-mod-01", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "修改授权策略", + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "有效期": "2027-01-01~2029-12-31" + }, + "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. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210,有效期是2027-01-01~2029-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "修改授权策略", + "策略列表": [ + { + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "有效期": "2027-01-01~2029-12-31" + } + ] + } + } + } + }, + { + "label": "h-c3-data-mod-02", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "修改授权策略", + "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "有效期": "2026-01-01~2030-12-31" + }, + "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. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "修改授权策略", + "策略列表": [ + { + "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "有效期": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "h-c3-data-mod-03", + "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": "h-c3-data-mod-04", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "修改授权策略", + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "有效期": "2026-08-01~2030-12-31" + }, + "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. 策略标识是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": "h-c3-nl-mod-06", + "entry": "from_text", + "input": { + "text": "把f9e8d7c6-b5a4-3210-fedc-ba9876543210和7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3的有效期都改成永久生效" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210,有效期是永久生效\n2. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "修改授权策略", + "策略列表": [ + { + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "有效期": "永久生效" + }, + { + "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "有效期": "永久生效" + } + ] + } + } + } + }, + { + "label": "h-c3-data-mod-05", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "修改授权策略", + "详情": [ + { + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "有效期": "永久生效" + }, + { + "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "有效期": "2027-02-01~2029-11-30" + } + ] + }, + "schema": { + "type": "object", + "properties": { + "操作类型": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "详情": { + "type": "array", + "description": "策略修改详情列表", + "items": { + "type": "object", + "properties": { + "策略标识": { + "type": "string", + "description": "要修改的策略唯一标识,UUID格式" + }, + "有效期": { + "type": "string", + "description": "新的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } + } + } + } + } + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210,有效期是永久生效\n2. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是2027-02-01~2029-11-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "修改授权策略", + "策略列表": [ + { + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "有效期": "永久生效" + }, + { + "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "有效期": "2027-02-01~2029-11-30" + } + ] + } + } + } + }, + { + "label": "h-c3-nl-mod-07", + "entry": "from_text", + "input": { + "text": "修改f9e8d7c6-b5a4-3210-fedc-ba9876543210的授权,有效期改成2026-09-01~2031-08-31" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210,有效期是2026-09-01~2031-08-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "修改授权策略", + "策略列表": [ + { + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "有效期": "2026-09-01~2031-08-31" + } + ] + } + } + } + }, + { + "label": "h-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": { + "操作类型": "删除授权策略", + "策略列表": [ + { + "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3" + } + ] + } + } + } + }, + { + "label": "h-c4-nl-del-02", + "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": { + "操作类型": "删除授权策略", + "策略列表": [ + { + "业务场景": "时延监测" + } + ] + } + } + } + }, + { + "label": "h-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": "success", + "slot_errors": [], + "params": { + "操作类型": "删除授权策略", + "策略列表": [ + { + "业务场景": "载波调度" + } + ] + } + } + } + }, + { + "label": "h-c4-data-del-01", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "删除授权策略", + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + }, + "schema": { + "type": "object", + "properties": { + "操作类型": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "策略标识": { + "type": "string", + "description": "要删除的策略唯一标识,UUID格式" + }, + "业务场景": { + "type": "string", + "description": "按业务场景条件删除" + } + } + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "删除授权策略", + "策略列表": [ + { + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + } + ] + } + } + } + }, + { + "label": "h-c4-data-del-02", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "删除授权策略", + "业务场景": "负载均衡" + }, + "schema": { + "type": "object", + "properties": { + "操作类型": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "策略标识": { + "type": "string", + "description": "要删除的策略唯一标识,UUID格式" + }, + "业务场景": { + "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": "h-c4-nl-del-05", + "entry": "from_text", + "input": { + "text": "删除a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d和f9e8d7c6-b5a4-3210-fedc-ba9876543210这两个授权策略" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d\n2. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "删除授权策略", + "策略列表": [ + { + "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + }, + { + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + } + ] + } + } + } + }, + { + "label": "h-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": { + "操作类型": "删除授权策略", + "策略列表": [ + { + "处置类型": "紧急扩容和降级服务" + } + ] + } + } + } + }, + { + "label": "h-c4-data-del-03", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "删除授权策略", + "详情": [ + { + "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + }, + { + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + } + ] + }, + "schema": { + "type": "object", + "properties": { + "操作类型": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "详情": { + "type": "array", + "description": "要删除的策略标识列表", + "items": { + "type": "object", + "properties": { + "策略标识": { + "type": "string", + "description": "要删除的策略唯一标识,UUID格式" + }, + "业务场景": { + "type": "string", + "description": "按业务场景条件删除" + } + } + } + } + } + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d\n2. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "删除授权策略", + "策略列表": [ + { + "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d" + }, + { + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + } + ] + } + } + } + }, + { + "label": "h-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": { + "操作类型": "查询授权策略", + "策略列表": [] + } + } + } + }, + { + "label": "h-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": { + "操作类型": "查询授权策略", + "策略列表": [ + { + "业务场景": "载波调度", + "处置类型": "紧急扩容" + } + ] + } + } + } + }, + { + "label": "h-c5-data-query-01", + "entry": "from_data_with_schema", + "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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "查询授权策略", + "策略列表": [] + } + } + } + }, + { + "label": "h-c5-data-query-02", + "entry": "from_data_with_schema", + "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## 动网操作的授权策略列表\n1. 业务场景是天线调整\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "查询授权策略", + "策略列表": [ + { + "业务场景": "天线调整" + } + ] + } + } + } + }, + { + "label": "h-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": { + "操作类型": "查询授权策略", + "策略列表": [ + { + "业务场景": "专线保障", + "处置类型": "故障切换" + } + ] + } + } + } + }, + { + "label": "h-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": { + "操作类型": "查询授权策略", + "策略列表": [ + { + "业务场景": "智慧工厂专网", + "处置类型": "协议降级" + } + ] + } + } + } + }, + { + "label": "h-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": { + "操作类型": "修改授权策略", + "策略列表": [ + { + "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "有效期": "2026-08-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "h-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": { + "操作类型": "查询授权策略", + "策略列表": [] + } + } + } + }, + { + "label": "h-c6-nl-mixed-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": { + "操作类型": "删除授权策略", + "策略列表": [ + { + "业务场景": "时延监测" + } + ] + } + } + } + }, + { + "label": "h-c6-data-mixed-02", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "修改授权策略", + "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "有效期": "2026-01-01~2030-12-31" + }, + "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,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "修改授权策略", + "策略列表": [ + { + "策略标识": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "有效期": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "h-c6-data-mixed-03", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "删除授权策略", + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + }, + "schema": { + "type": "object", + "properties": { + "操作类型": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "策略标识": { + "type": "string", + "description": "要删除的策略唯一标识,UUID格式" + }, + "业务场景": { + "type": "string", + "description": "按业务场景条件删除" + } + } + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n删除授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 策略标识是f9e8d7c6-b5a4-3210-fedc-ba9876543210\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "删除授权策略", + "策略列表": [ + { + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210" + } + ] + } + } + } + }, + { + "label": "h-c6-data-mixed-04", + "entry": "from_data_with_schema", + "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## 动网操作的授权策略列表\n1. 业务场景是时延监测\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "查询授权策略", + "策略列表": [ + { + "业务场景": "时延监测" + } + ] + } + } + } + }, + { + "label": "h-c6-data-mixed-06", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "新增授权策略", + "详情": [ + { + "业务场景": "应急广播", + "处置类型": "优先保障", + "操作名称": "参数核查", + "有效期": "永久生效" + }, + { + "业务场景": "卫星回传", + "处置类型": "现场处理", + "操作名称": "故障切换", + "有效期": "2026-01-01~2030-12-31" + } + ] + }, + "schema": { + "type": "object", + "properties": { + "操作类型": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "详情": { + "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-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "应急广播", + "处置类型": "优先保障", + "操作名称": "参数核查", + "有效期": "永久生效" + }, + { + "业务场景": "卫星回传", + "处置类型": "现场处理", + "操作名称": "故障切换", + "有效期": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "h-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": "h-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": "h-c6-nl-mixed-06", + "entry": "from_text", + "input": { + "text": "新增三条授权:第一条业务场景卫星回传,处置类型现场处理,操作名称故障切换,有效期永久生效;第二条业务场景时延监测,处置类型紧急扩容,操作名称路由调优,有效期从2026年9月1日到2031年12月31日;第三条业务场景远程医疗,处置类型优先保障,操作名称流量调度,有效期2026-01-01~2030-12-31" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是卫星回传,处置类型是现场处理,操作名称是故障切换,有效期是永久生效\n2. 业务场景是时延监测,处置类型是紧急扩容,操作名称是路由调优,有效期是2026-09-01~2031-12-31\n3. 业务场景是远程医疗,处置类型是优先保障,操作名称是流量调度,有效期是2026-01-01~2030-12-31\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "卫星回传", + "处置类型": "现场处理", + "操作名称": "故障切换", + "有效期": "永久生效" + }, + { + "业务场景": "时延监测", + "处置类型": "紧急扩容", + "操作名称": "路由调优", + "有效期": "2026-09-01~2031-12-31" + }, + { + "业务场景": "远程医疗", + "处置类型": "优先保障", + "操作名称": "流量调度", + "有效期": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "h-c6-data-mixed-12", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "新增授权策略", + "详情": [ + { + "场景": "智慧港口", + "处置": "降级服务", + "操作": "参数核查", + "时间": "永久生效", + "备注": "常态化" + }, + { + "场景": "智能电网", + "处置": "紧急扩容", + "操作": "路由调优", + "时间": "2026-07-01~2030-07-18" + }, + { + "场景": "载波调度", + "处置": "优先保障", + "操作": "流量调度", + "时间": "2027-05-15~2029-11-30", + "备注": "试运行" + } + ] + }, + "schema": { + "type": "object", + "properties": { + "操作类型": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "详情": { + "type": "array", + "description": "策略详情列表", + "items": { + "type": "object", + "properties": { + "场景": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "处置": { + "type": "string", + "description": "动网操作的处置类型" + }, + "操作": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "时间": { + "type": "string", + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } + } + } + } + } + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是智慧港口,处置类型是降级服务,操作名称是参数核查,有效期是永久生效\n2. 业务场景是智能电网,处置类型是紧急扩容,操作名称是路由调优,有效期是2026-07-01~2030-07-18\n3. 业务场景是载波调度,处置类型是优先保障,操作名称是流量调度,有效期是2027-05-15~2029-11-30\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "智慧港口", + "处置类型": "降级服务", + "操作名称": "参数核查", + "有效期": "永久生效" + }, + { + "业务场景": "智能电网", + "处置类型": "紧急扩容", + "操作名称": "路由调优", + "有效期": "2026-07-01~2030-07-18" + }, + { + "业务场景": "载波调度", + "处置类型": "优先保障", + "操作名称": "流量调度", + "有效期": "2027-05-15~2029-11-30" + } + ] + } + } + } + }, + { + "label": "h-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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "策略列表", + "code": "content.format_error" + } + ] + } + } + }, + { + "label": "h-c6-data-mixed-13", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "修改授权策略", + "详情": [ + { + "策略标识": "f9e8d7c6-b5a4-3210-fedc-ba9876543210", + "有效期": "永久生效" + }, + { + "策略标识": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "有效期": "到2028-12-31为止" + } + ] + }, + "schema": { + "type": "object", + "properties": { + "操作类型": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "详情": { + "type": "array", + "description": "策略修改详情列表", + "items": { + "type": "object", + "properties": { + "策略标识": { + "type": "string", + "description": "要修改的策略唯一标识,UUID格式" + }, + "有效期": { + "type": "string", + "description": "新的生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } + } + } + } + } + } + }, + "expected": { + "client": { + "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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "有效期", + "code": "content.format_error" + } + ] + } + } + }, + { + "label": "h-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": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "专线保障", + "处置类型": "紧急扩容", + "操作名称": "天线调整", + "有效期": "2026-08-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "h-c6-data-mixed-14", + "entry": "from_data_with_schema", + "input": { + "data": { + "操作类型": "新增授权策略", + "详情": [ + { + "场景": "物联监测", + "处置": "故障切换", + "操作": "频谱重耕", + "时间": "永久生效" + } + ] + }, + "schema": { + "type": "object", + "properties": { + "操作类型": { + "type": "string", + "enum": [ + "新增授权策略", + "修改授权策略", + "删除授权策略", + "查询授权策略" + ] + }, + "详情": { + "type": "array", + "description": "策略详情列表", + "items": { + "type": "object", + "properties": { + "场景": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "处置": { + "type": "string", + "description": "动网操作的处置类型" + }, + "操作": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "时间": { + "type": "string", + "description": "生效时间区间,YYYY-MM-DD~YYYY-MM-DD或永久生效" + } + } + } + } + } + } + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是物联监测,处置类型是故障切换,操作名称是频谱重耕,有效期是永久生效\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "操作类型": "新增授权策略", + "策略列表": [ + { + "业务场景": "物联监测", + "处置类型": "故障切换", + "操作名称": "频谱重耕", + "有效期": "永久生效" + } + ] + } + } + } + } + ] +} 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..35392d1e --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/scenarios.json @@ -0,0 +1,521 @@ +{ + "scenarios": [ + { + "label": "c1-nl-add-01", + "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": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" + } + ] + } + } + } + }, + { + "label": "c1-nl-add-01-varname", + "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": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policies": [ + { + "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": "c2-nl-multi-01", + "entry": "from_text", + "input": { + "text": "新增两条授权策略:第一条,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31;第二条,业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期从2026-06-01到2030-06-18" + }, + "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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "success", + "slot_errors": [], + "params": { + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" + }, + { + "policyId": null, + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" + } + ] + } + } + } + }, + { + "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. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "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": "c3-nl-mod-06-varfields", + "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", + "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": "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": "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": "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": { + "operationType": "删除授权策略", + "policyList": [ + { + "policyId": "ABC-123", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": null + } + ] + } + } + } + }, + { + "label": "b3-nl-invalid-mod-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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.value_not_allowed" + } + ], + "params": { + "operationType": "修改授权策略", + "policyList": [ + { + "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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.value_not_allowed" + } + ] + } + }, + "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": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 content.value_not_allowed" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + } + } + } + } + } + } + }, + { + "label": "b1-nl-missing-01", + "entry": "from_text", + "input": { + "text": "加个校园专网的授权,处置用业务抢通,做个隧道调优,有效期先不填后面补" + }, + "expected": { + "client": { + "promptText": "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。\n\n## 动网操作的授权策略列表\n1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优\n\n## 预期输出\n1. 授权操作执行结果,取值范围: 成功、失败、部分成功;\n2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>;\n3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因;" + }, + "server": { + "outcome": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.entry_field_missing" + } + ] + } + } + }, + { + "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": "negotiation.semantic_rejected", + "slot_errors": [ + { + "slot_name": "policyList", + "code": "content.format_error" + } + ] + } + } + }, + { + "label": "a-data-starve-01", + "entry": "from_data_with_schema", + "input": { + "data": { + "scene": "校园专网", + "actionType": "业务抢通" + }, + "schema": { + "type": "object", + "properties": { + "scene": { + "type": "string", + "description": "动网操作支撑的业务场景" + }, + "actionType": { + "type": "string", + "description": "动网操作的处置类型" + } + } + } + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided" + } + ] + } + } + }, + { + "label": "a-nl-neg-01", + "entry": "from_text", + "input": { + "text": "不要新增校园专网的授权策略,我还没准备好" + }, + "expected": { + "client": { + "outcome": "slot.not_provided", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "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 new file mode 100644 index 00000000..99a703cd --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/authz-policy/smoke-io-transcript.md @@ -0,0 +1,1503 @@ +# Authorization-T Demo 冒烟测试原始输入输出记录 + +- 位置:`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 + +--- + +## c1-nl-add-01 — ✅ match + +### 客户端(生成) + +**原始输入**(from_text) + +> 帮我新增一个授权策略,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31 + +**生成结果:成功(渲染后 prompt)** + +``` +## 授权策略的操作类型 +新增授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-01-01~2030-12-31 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +**提参理由(LLM reasoning)**:输入中出现“新增”这一合法取值的简写,按规则13a映射为“新增授权策略”。策略列表为新增形,输入提供了业务场景、处置类型、操作名称、有效期四个字段,日期“2026-01-01到2030-12-31”按约束归一为“2026-01-01~2030-12-31”,并按编号1及全角逗号组织为列表条目。 + +### 服务端(校验 + 提参) + +**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` + +**提参结果**: + +```json +{ + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" + } + ] +} +``` + +**校验理由(LLM reasoning)**:操作类型为新增授权策略,属于 schema 允许的枚举值。动网操作的授权策略列表包含1条策略;新增授权策略的模板必填字段为业务场景、处置类型、操作名称、有效期,该条目均已提供。有效期起止日期有效且未倒置,日期区间形态未被约束明确排除,不构成格式违规。policyId 在新增场景下非必填,缺失不报错。因此 semantic_verdict 为 true,errors 为空。 + +**键名泄漏扫描**:无泄漏 + +**判定**:match=True(client_prompt=True) + +--- + +## c1-nl-add-01-varname — ✅ match +[【差分对】配对基线:c1-nl-add-01] + +### 客户端(生成) + +**原始输入**(from_text) + +> 帮我新增一个授权策略,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31 + +**生成结果:成功(渲染后 prompt)** + +``` +## 授权策略的操作类型 +新增授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-01-01~2030-12-31 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +**提参理由(LLM reasoning)**:输入中出现“新增”且处于新增授权策略的指令语境,按封闭取值范围规则13a映射为“新增授权策略”。策略列表按新增形字段顺序提取:业务场景、处置类型、操作名称、有效期,并将日期区间归一化为YYYY-MM-DD~YYYY-MM-DD格式;操作类型为新增,列表已提供,因此不报告slot.not_provided。 + +### 服务端(校验 + 提参) + +**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` + +**提参结果**: + +```json +{ + "operationType": "新增授权策略", + "policies": [ + { + "pid": null, + "domain": "校园专网", + "handling": "业务抢通", + "opName": "隧道调优", + "effectivePeriod": "2026-01-01~2030-12-31" + } + ] +} +``` + +**校验理由(LLM reasoning)**:操作类型为新增授权策略,取值在 schema 枚举范围内。新增操作按模板正文要求必填业务场景、处置类型、操作名称、有效期,输入列表条目均完整提供;未提供策略标识、创建时间、最后修改时间,但新增操作不要求这些字段,不构成缺失。有效期格式为日期区间且起始早于结束,未被约束明确禁止,格式变体通过。参数提取按 schema 结构完成,pid 未提供故为 null。 + +**键名泄漏扫描**:无泄漏 + +**判定**:match=True(client_prompt=True) + +--- + +## c2-nl-multi-01 — ✅ match + +### 客户端(生成) + +**原始输入**(from_text) + +> 新增两条授权策略:第一条,业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期从2026-01-01到2030-12-31;第二条,业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期从2026-06-01到2030-06-18 + +**生成结果:成功(渲染后 prompt)** + +``` +## 授权策略的操作类型 +新增授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-01-01~2030-12-31 +2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-06-01~2030-06-18 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +**提参理由(LLM reasoning)**:输入中出现“新增”且处于指令语境,按封闭取值范围规则映射为合法值“新增授权策略”。策略列表提取两条编号条目,并将“有效期从...到...”归一为“有效期是YYYY-MM-DD~YYYY-MM-DD”格式,字段顺序和值均保留。 + +### 服务端(校验 + 提参) + +**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` + +**提参结果**: + +```json +{ + "operationType": "新增授权策略", + "policyList": [ + { + "policyId": null, + "scene": "校园专网", + "actionType": "业务抢通", + "operationName": "隧道调优", + "validityPeriod": "2026-01-01~2030-12-31" + }, + { + "policyId": null, + "scene": "医疗专线", + "actionType": "业务恢复", + "operationName": "频段调整", + "validityPeriod": "2026-06-01~2030-06-18" + } + ] +} +``` + +**校验理由(LLM reasoning)**:操作类型为“新增授权策略”,在枚举范围内。动网操作的授权策略列表包含两条记录,均提供了创建授权策略所需的业务场景、处置类型、操作名称和有效期;policyId在创建场景下非必填,未提供时按schema输出null。两条有效期均为日期区间,起始日期未晚于结束日期,且日期区间写法未被约束明确排除,格式变体默认通过。综上无语义或格式错误。 + +**键名泄漏扫描**:无泄漏 + +**判定**:match=True(client_prompt=True) + +--- + +## c3-nl-mod-06 — ✅ match + +### 客户端(生成) + +**原始输入**(from_text) + +> 把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3和a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的有效期都改成永久生效 + +**生成结果:成功(渲染后 prompt)** + +``` +## 授权策略的操作类型 +修改授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效 +2. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是永久生效 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +**提参理由(LLM reasoning)**:输入中“改成”属于修改授权策略的认可同义对,因此“授权策略的操作类型”映射为“修改授权策略”。修改对象为两个策略标识,有效期目标为“永久生效”,按修改形列表格式合并提取为两个编号条目。 + +### 服务端(校验 + 提参) + +**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` + +**提参结果**: + +```json +{ + "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": "永久生效" + } + ] +} +``` + +**校验理由(LLM reasoning)**:操作类型为修改授权策略,取值在enum范围内;修改操作下条目需提供策略标识与有效期,两条策略均提供了策略标识且有效期为合法的“永久生效”,未发现必填字段缺失、格式错误或语义冲突;按schema提取策略列表,并将未提供的业务场景、处置类型、操作名称置为null。 + +**键名泄漏扫描**:无泄漏 + +**判定**:match=True(client_prompt=True) + +--- + +## c3-nl-mod-06-varfields — ✅ match +[【差分对】配对基线:c3-nl-mod-06] + +### 客户端(生成) + +**原始输入**(from_text) + +> 把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3和a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d的有效期都改成永久生效 + +**生成结果:成功(渲染后 prompt)** + +``` +## 授权策略的操作类型 +修改授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,有效期是永久生效 +2. 策略标识是a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d,有效期是永久生效 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +**提参理由(LLM reasoning)**:输入中的“改成”属于“修改授权策略”的认可同义对,映射为“修改授权策略”。两个UUID均为修改对象,有效期统一改为“永久生效”,按修改形策略列表格式拆分为两个编号条目。 + +### 服务端(校验 + 提参) + +**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` + +**提参结果**: + +```json +{ + "operationType": "修改授权策略", + "policyList": [ + { + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "newValidityPeriod": "永久生效" + }, + { + "policyId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "newValidityPeriod": "永久生效" + } + ] +} +``` + +**校验理由(LLM reasoning)**:(未采集) + +**键名泄漏扫描**:无泄漏 + +**判定**:match=True(client_prompt=True) + +--- + +## c4-nl-del-01 — ✅ match + +### 客户端(生成) + +**原始输入**(from_text) + +> 删除7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权策略 + +**生成结果:成功(渲染后 prompt)** + +``` +## 授权策略的操作类型 +删除授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +**提参理由(LLM reasoning)**:操作类型:输入开头出现“删除”,属于合法取值“删除授权策略”的简写,按封闭取值范围规则直接映射为“删除授权策略”。策略列表:删除操作需提供策略标识,输入显式给出 UUID 7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,按删除条目格式组织为“1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3”。 + +### 服务端(校验 + 提参) + +**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` + +**提参结果**: + +```json +{ + "operationType": "删除授权策略", + "policyList": [ + { + "policyId": "7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": null + } + ] +} +``` + +**校验理由(LLM reasoning)**:操作类型为删除授权策略,属于枚举允许范围;删除操作按模板正文仅需策略标识,输入第1条提供了有效策略标识,未发现必填缺失、格式错误或语义冲突。按参数schema提取操作类型和策略列表,未提供的字段置null。 + +**键名泄漏扫描**:无泄漏 + +**判定**:match=True(client_prompt=True) + +--- + +## c5-nl-query-01 — ✅ match + +### 客户端(生成) + +**原始输入**(from_text) + +> 列出所有授权策略 + +**生成结果:成功(渲染后 prompt)** + +``` +## 授权策略的操作类型 +查询授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +**提参理由(LLM reasoning)**:输入中的“列出”属于“查看/检索/搜索/查找/列出/列举→查询授权策略”的认可同义对,因此将“授权策略的操作类型”映射为“查询授权策略”。输入未提供具体的授权策略列表内容或带字段标签的查询条件,且查询授权策略下“动网操作的授权策略列表”为选填,因此该 slot 取 null,不报告错误。 + +### 服务端(校验 + 提参) + +**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` + +**提参结果**: + +```json +{ + "operationType": "查询授权策略", + "policyList": [] +} +``` + +**校验理由(LLM reasoning)**:操作类型章节的参数值行为“查询授权策略”,属于 schema 枚举允许的取值。动网操作的授权策略列表章节标题下没有非空参数值行,且查询操作下该列表为选填,因此不构成缺失。按 schema 提取 operationType 为“查询授权策略”,policyList 未提供时输出空数组。整体校验通过。 + +**键名泄漏扫描**:无泄漏 + +**判定**:match=True(client_prompt=True) + +--- + +## b4-nl-bad-id-01 — ✅ match + +### 客户端(生成) + +**原始输入**(from_text) + +> 删除ABC-123这个授权策略 + +**生成结果:成功(渲染后 prompt)** + +``` +## 授权策略的操作类型 +删除授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +1. 策略标识是ABC-123 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +**提参理由(LLM reasoning)**:输入中“删除”是合法取值范围中“删除授权策略”的简写,按规则13a直接映射;“ABC-123”是删除操作指向的策略标识,按删除形列表约束整理为“1. 策略标识是ABC-123”。未发现缺失或非法值。 + +### 服务端(校验 + 提参) + +**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` + +**提参结果**: + +```json +{ + "operationType": "删除授权策略", + "policyList": [ + { + "policyId": "ABC-123", + "scene": null, + "actionType": null, + "operationName": null, + "validityPeriod": null + } + ] +} +``` + +**校验理由(LLM reasoning)**:操作类型“删除授权策略”在枚举范围内;删除授权策略的必填参数仅为授权策略标识,输入列表第1条提供了策略标识ABC-123,非空且无明显格式或取值违规,因此校验通过;其余字段在删除场景下未要求,按参数schema输出为null。 + +**键名泄漏扫描**:无泄漏 + +**判定**:match=True(client_prompt=True) + +--- + +## b3-nl-invalid-mod-01 — ✅ match + +### 客户端(生成) + +**原始输入**(from_text) + +> 把7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3这个授权的业务场景改成校园专网 + +**生成结果:成功(渲染后 prompt)** + +``` +## 授权策略的操作类型 +修改授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +1. 策略标识是7d8c7b00-3c8c-4f8e-9b1e-9b17b6a3e5c3,业务场景改为校园专网 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +**提参理由(LLM reasoning)**:输入中的“改成”属于操作类型同义对清单中“修改授权策略”的认可同义词,因此“授权策略的操作类型”提取为“修改授权策略”。修改类输入中提供了策略标识 UUID 和修改描述“业务场景改成校园专网”,按规则17将作用对象标识与修改描述合并保留在“动网操作的授权策略列表”中,并格式化为单条条目。未发现缺失或违反封闭取值范围的情况。 + +### 服务端(校验 + 提参) + +**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 即上方客户端渲染结果,此处不重复。) + +**校验与提参结果**:`negotiation.semantic_rejected` + +```json +[ + { + "slot_name": "policyList", + "code": "content.value_not_allowed", + "message": "「动网操作的授权策略列表」的取值「业务场景改为校园专网」不在允许范围内", + "facts": { + "section_label": "动网操作的授权策略列表", + "actual": "业务场景改为校园专网" + } + } +] +``` + +**校验理由(LLM reasoning)**:操作类型为“修改授权策略”,已提取。策略列表第1条提供策略标识和将业务场景改为校园专网的意图。模板正文规定修改授权策略仅支持修改有效期,因此该业务场景修改意图超出可修改范围,判定为content.value_not_allowed,semantic_verdict=false。params按参数schema提取:policyId取自策略标识,scene取值为校园专网,其余未提供字段置null。 + +**键名泄漏扫描**:无泄漏 + +**判定**:match=True(client_prompt=False) + +--- + +## b2-nl-format-01 — ✅ match + +### 客户端(生成) + +**原始输入**(from_text) + +> 新增授权,场景校园专网,处置业务抢通,操作隧道调优,有效期2026-13-01~2030-12-31 + +**生成结果:成功(渲染后 prompt)** + +``` +## 授权策略的操作类型 +新增授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是2026-13-01~2030-12-31 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +**提参理由(LLM reasoning)**:“新增授权”包含封闭取值范围内的合法简写“新增”,按规则13a映射为“新增授权策略”。策略列表按新增形组织为单条,字段分别从“场景校园专网”“处置业务抢通”“操作隧道调优”“有效期2026-13-01~2030-12-31”映射为业务场景、处置类型、操作名称、有效期,并保留日期原文(已为 YYYY-MM-DD~YYYY-MM-DD 区间格式,月值异常按规则16原样提取,不在此阶段拦截)。 + +### 服务端(校验 + 提参) + +**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 即上方客户端渲染结果,此处不重复。) + +**校验与提参结果**:`negotiation.semantic_rejected` + +```json +[ + { + "slot_name": "policyList", + "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”无效" + } + } +] +``` + +**校验理由(LLM reasoning)**:(未采集) + +**键名泄漏扫描**:无泄漏 + +**判定**:match=True(client_prompt=True) + +--- + +## b2-nl-format-01-varreq — ✅ match +[【差分对】配对基线:b2-nl-format-01] + +### 客户端(生成) + +**原始输入**(from_text) + +> 新增一个授权,业务场景校园专网,处置类型网络出了问题要赶紧恢复,操作名称频段调整,有效期2026-01-01~2030-12-31 + +**生成结果:成功(渲染后 prompt)** + +``` +## 授权策略的操作类型 +新增授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +1. 业务场景是校园专网,处置类型是网络出了问题要赶紧恢复,操作名称是频段调整,有效期是2026-01-01~2030-12-31 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +**提参理由(LLM reasoning)**:输入中的“新增”命中封闭取值范围中“新增授权策略”的简写,按规则13a映射为“新增授权策略”。由于操作类型为新增,授权策略列表为必填,输入中依次给出了业务场景、处置类型、操作名称、有效期四个新增形字段,按值约束顺序整理为编号条目,日期已符合YYYY-MM-DD~YYYY-MM-DD格式,故直接保留。未发现缺失或非法值,slot_errors为空。 + +### 服务端(校验 + 提参) + +**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": "动网操作的处置类型,应为简短的处置动作短语(如业务抢通、业务优化、路由调整),非短语式的整句描述判 content.value_not_allowed" + }, + "operationName": { + "type": "string", + "description": "所授权的动网操作的具体名称" + }, + "validityPeriod": { + "type": "string", + "description": "授权策略的生效时间区间,YYYY-MM-DD~YYYY-MM-DD格式或\"永久生效\";须为完整区间(仅单个日期视为区间不完整),且起始日期必须早于结束日期" + } + } + } + } + } +} +``` + +(输入 prompt 即上方客户端渲染结果,此处不重复。) + +**校验与提参结果**:`negotiation.semantic_rejected` + +```json +[ + { + "slot_name": "policyList", + "code": "content.value_not_allowed", + "message": "「动网操作的授权策略列表」的取值「网络出了问题要赶紧恢复」不在允许范围内", + "facts": { + "section_label": "动网操作的授权策略列表", + "actual": "网络出了问题要赶紧恢复" + } + } +] +``` + +**校验理由(LLM reasoning)**:操作类型“新增授权策略”在枚举范围内;动网操作的授权策略列表包含1条,新增必填的业务场景、处置类型、操作名称、有效期均已提供,有效期格式为完整区间且起始早于结束;但处置类型“网络出了问题要赶紧恢复”为整句描述,不符合参数 schema 要求的简短处置动作短语,故判定 content.value_not_allowed,整体校验不通过。 + +**键名泄漏扫描**:无泄漏 + +**判定**:match=True(client_prompt=True) + +--- + +## b1-nl-missing-01 — ✅ match + +### 客户端(生成) + +**原始输入**(from_text) + +> 加个校园专网的授权,处置用业务抢通,做个隧道调优,有效期先不填后面补 + +**生成结果:成功(渲染后 prompt)** + +``` +## 授权策略的操作类型 +新增授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +**提参理由(LLM reasoning)**:“加个”命中同义对清单中的“加个→新增授权策略”,因此操作类型提取为“新增授权策略”。策略列表中,“校园专网”对应业务场景,“业务抢通”对应处置类型,“隧道调优”对应操作名称;“有效期先不填后面补”属于明确的字段缺省声明,按规则不作为字段值提取,因此仅保留已提供的三个字段并按要求格式化为编号条目。未触发 slot_errors。 + +### 服务端(校验 + 提参) + +**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 即上方客户端渲染结果,此处不重复。) + +**校验与提参结果**:`negotiation.semantic_rejected` + +```json +[ + { + "slot_name": "policyList", + "code": "content.entry_field_missing", + "message": "「动网操作的授权策略列表」第 1 条缺少必填字段「有效期」", + "facts": { + "section_label": "动网操作的授权策略列表", + "index": "1", + "field_label": "有效期" + } + } +] +``` + +**校验理由(LLM reasoning)**:操作类型为“新增授权策略”,在参数 schema 枚举范围内;模板正文要求新增授权策略列表条目必填业务场景、处置类型、操作名称、有效期。输入列表第1条提供了业务场景、处置类型和操作名称,但未提供有效期,因此校验失败并报告 content.entry_field_missing。参数提取按 schema 结构进行,缺失字段置 null。 + +**键名泄漏扫描**:无泄漏 + +**判定**:match=True(client_prompt=True) + +--- + +## c6-nl-mixed-07 — ✅ match + +### 客户端(生成) + +**原始输入**(from_text) + +> 新增三条授权:第一条业务场景校园专网,处置类型业务抢通,操作名称隧道调优,有效期永久生效;第二条业务场景医疗专线,处置类型业务恢复,操作名称频段调整,有效期2026-13-01~2030-12-31;第三条业务场景政企专网,处置类型业务优化,操作名称路由调整,有效期2027-03-15~2029-09-30 + +**生成结果:成功(渲染后 prompt)** + +``` +## 授权策略的操作类型 +新增授权策略 + +## 授权策略的操作描述 +请根据<授权策略的操作类型>和<动网操作的授权策略列表>完成相应的授权操作,按照<预期输出>中定义的结构返回授权策略的操作执行结果。<预期输出>表示预期返回内容。 + +## 动网操作的授权策略列表 +1. 业务场景是校园专网,处置类型是业务抢通,操作名称是隧道调优,有效期是永久生效 +2. 业务场景是医疗专线,处置类型是业务恢复,操作名称是频段调整,有效期是2026-13-01~2030-12-31 +3. 业务场景是政企专网,处置类型是业务优化,操作名称是路由调整,有效期是2027-03-15~2029-09-30 + +## 预期输出 +1. 授权操作执行结果,取值范围: 成功、失败、部分成功; +2. 授权操作执行成功时,返回执行成功的<动网操作的授权策略列表>; +3. 授权操作执行失败或部分成功时,返回失败列表,包含授权策略和失败原因; +``` + +**提参理由(LLM reasoning)**:输入“新增三条授权”中的“新增”符合封闭取值范围简写映射规则,提取为“新增授权策略”。策略列表提取三条条目,按字段顺序(业务场景、处置类型、操作名称、有效期)和编号格式组织;第二条有效期保留原文日期格式,不因格式异常而丢弃。 + +### 服务端(校验 + 提参) + +**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 即上方客户端渲染结果,此处不重复。) + +**校验与提参结果**:`negotiation.semantic_rejected` + +```json +[ + { + "slot_name": "policyList", + "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月)" + } + } +] +``` + +**校验理由(LLM reasoning)**:(未采集) + +**键名泄漏扫描**:无泄漏 + +**判定**: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.not_provided", + "message": "输入中未提供「授权策略的操作类型」。", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided", + "message": "输入中未提供「授权策略的操作类型」。", + "facts": { + "slot_label": "授权策略的操作类型" + } + } + ] +} +``` + +**提参理由(LLM reasoning)**:输入中仅有 scene=校园专网、actionType=业务抢通,未出现“新增、修改、删除、查询”等操作类型合法取值或其认可同义词,按封闭取值范围规则不能推断,故“授权策略的操作类型”置为 null 并报告 slot.not_provided。scene 对应“业务场景”,actionType 对应“处置类型”,这两项属于动网操作授权策略列表中允许的查询条件字段,因此按“字段名是值”格式合并为一条编号列表。 + +### 服务端(校验 + 提参) + +(客户端生成失败路径,服务端未被调用。) + +**判定**:match=True(client_prompt=None) + +--- + +## a-nl-neg-01 — ✅ match + +### 客户端(生成) + +**原始输入**(from_text) + +> 不要新增校园专网的授权策略,我还没准备好 + +**生成结果:失败** + +```json +{ + "code": "slot.not_provided", + "message": "输入中未提供「授权策略的操作类型」。", + "slot_errors": [ + { + "slot_name": "授权策略的操作类型", + "code": "slot.not_provided", + "message": "输入中未提供「授权策略的操作类型」。", + "facts": { + "slot_label": "授权策略的操作类型" + } + } + ] +} +``` + +**提参理由(LLM reasoning)**:输入中虽然出现“新增”,但位于“不要新增……我还没准备好”的否定/排除语义中,依据规则3不得作为正向slot值提取,因此“授权策略的操作类型”视为缺失并报告slot.not_provided;“动网操作的授权策略列表”中的“校园专网”同样处于否定语义中,不提取,且操作类型未确定,不触发该列表的必填条件,故返回null。 + +### 服务端(校验 + 提参) + +(客户端生成失败路径,服务端未被调用。) + +**判定**:match=True(client_prompt=None) + +--- + +## 差分对判读汇总 + +| 差分对 | 基线半 | 变异半 | 判读 | +|---|---|---|---| +| c1-nl-add-01/-varname | ✅ | ✅ | 健康 | +| c3-nl-mod-06/-varfields | ✅ | ✅ | 健康 | +| b2-nl-format-01/-varreq | ✅ | ✅ | 健康 | 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..d71ae962 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/negotiation/eval/eval-suite.json @@ -0,0 +1,352 @@ +{ + "suite": "negotiation-interface-eval", + "description": "Private-line complaint negotiation closed-loop evaluation against the bundled private-line-complaint template. Two tracks of ten cases each: fromData feeds structured JSON (every key maps to one field value) and fromText feeds natural language; run one track in isolation with --channel fromData|fromText. Each case drives the negotiation prompt interfaces in message order: Task-T generation -> 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. The suite's task schema refines the template's coarse 任务上下文 into four sub-fields (投诉分类/问题发生时间/OSS侧事件流水号/投诉详情, per the template's own requirement list). Generation gate: the bundled slot schema requires 任务对象 and 任务上下文, so an input missing either fails fast at generation; a required sub-field that is missing or invalid inside a non-blank context is detected at validation and negotiated. Coverage matrix per track: complete input (no negotiation), required fields missing (fail-fast or negotiated), dual sub-fields missing, optional fields missing (no negotiation), invalid values (object form, category enum, sequence-number format), field misplacement, colloquial/inferable input, and a negative fill whose value violates the constraint. fromText cases that reach the fill round carry client_data: the structured knowledge the client itself holds (what its input text quoted), which is what the fill round re-renders from. 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": { + "type": "object", + "properties": { + "任务对象": { + "type": "string", + "description": "专线业务对象标识,用于定位被投诉的专线业务", + "x-a2at-value-constraint": "专线名称:网络侧EMS网管的专线对象标识。接入端口名称:网络侧EMS网管的接入端口名称,格式为:网元名称+单板号+单板型号+端口号。接入端口资源ID:网络侧EMS网管的接入端口资源对象标识。三者提供其一。" + }, + "投诉分类": { + "type": "string", + "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": ["任务对象", "投诉分类", "OSS侧事件流水号"] + }, + "negotiation_phrasing": { + "missing_item_hint": "请提供{slot}:{description}", + "propose_relationship": "所列字段均为必选,多个字段之间为AND关系;缺少任一字段均无法启动诊断", + "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", + "OSS侧事件流水号": "event-id-20260602-08841", + "投诉详情": "客户业务全部不通,柜面交易无法办理。" + }, + "cases": [ + { + "id": "PLC-D01", + "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-D02", + "channel": "fromData", + "intent": "缺任务对象(bundled required 槽):生成期 fail-fast,任务无法生成,协商不触发", + "input_data": { + "任务对象": "", + "投诉分类": "专线中断", + "问题发生时间": "2026-06-02T14:30:00Z", + "OSS侧事件流水号": "event-id-20260602-08841", + "投诉详情": "客户业务全部不通,柜面交易无法办理。" + }, + "expect": { + "negotiation_needed": false, + "missing_slots": [], + "generation_fails": true + } + }, + { + "id": "PLC-D03", + "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-D04", + "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-D05", + "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-D06", + "channel": "fromData", + "intent": "可选字段缺失(问题发生时间/投诉详情为空),不应触发协商", + "input_data": { + "任务对象": "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17", + "投诉分类": "专线中断", + "问题发生时间": "", + "OSS侧事件流水号": "event-id-20260602-08841", + "投诉详情": "" + }, + "expect": { + "negotiation_needed": false, + "missing_slots": [] + } + }, + { + "id": "PLC-D07", + "channel": "fromData", + "intent": "任务对象值无效(非端口名/资源ID/专线名称三种形态),触发任务对象协商", + "input_data": { + "任务对象": "客户的专线", + "投诉分类": "专线质差", + "问题发生时间": "2026-06-18T09:00:00Z", + "OSS侧事件流水号": "event-id-20260618-07721", + "投诉详情": "网页打开缓慢,视频卡顿。" + }, + "expect": { + "negotiation_needed": true, + "missing_slots": ["任务对象"], + "fill_completes": true + } + }, + { + "id": "PLC-D08", + "channel": "fromData", + "intent": "投诉分类值违反枚举约束(非中断/质差),触发投诉分类协商", + "input_data": { + "任务对象": "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17", + "投诉分类": "网络有点问题", + "问题发生时间": "2026-06-18T09:00:00Z", + "OSS侧事件流水号": "event-id-20260618-07721", + "投诉详情": "客户已在客服渠道催单,请尽快处理。" + }, + "expect": { + "negotiation_needed": true, + "missing_slots": ["投诉分类"], + "fill_completes": true + } + }, + { + "id": "PLC-D09", + "channel": "fromData", + "intent": "OSS侧事件流水号值格式错误(非 event-id- 开头标识),触发流水号协商", + "input_data": { + "任务对象": "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17", + "投诉分类": "专线中断", + "问题发生时间": "2026-06-02T14:30:00Z", + "OSS侧事件流水号": "12345", + "投诉详情": "客户已在客服渠道催单,请尽快处理。" + }, + "expect": { + "negotiation_needed": true, + "missing_slots": ["OSS侧事件流水号"], + "fill_completes": true + } + }, + { + "id": "PLC-D10", + "channel": "fromData", + "intent": "负例补槽:缺投诉分类,补入违反枚举约束的值,二次校验应拒绝(测校验器对补槽值的把关)", + "input_data": { + "任务对象": "接入端口名称:P311-会展中心-ATN910C-05-TPA2EG8-03", + "投诉分类": "", + "问题发生时间": "2026-06-18T09:00:00Z", + "OSS侧事件流水号": "event-id-20260618-07721", + "投诉详情": "客户已在客服渠道催单,请尽快处理。" + }, + "fill_override": { + "投诉分类": "不确定是什么问题" + }, + "expect": { + "negotiation_needed": true, + "missing_slots": ["投诉分类"], + "fill_completes": false + } + }, + { + "id": "PLC-T01", + "channel": "fromText", + "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": [] + } + }, + { + "id": "PLC-T02", + "channel": "fromText", + "intent": "口语化完全缺专线标识(bundled required 槽):生成期 fail-fast,任务无法生成,协商不触发", + "input_text": "我们的专线出问题了,业务全部都断了,完全用不了。问题发生时间:2026-06-02T14:30:00Z。OSS侧事件流水号:event-id-20260602-08841。", + "expect": { + "negotiation_needed": false, + "missing_slots": [], + "generation_fails": true + } + }, + { + "id": "PLC-T03", + "channel": "fromText", + "intent": "未描述任何故障现象(投诉分类无从判定为中断或质差),触发投诉分类协商并补齐闭环", + "input_text": "接入端口:P311-会展中心-ATN910C-05-TPA2EG8-03。OSS侧事件流水号:event-id-20260618-07721。", + "client_data": { + "任务对象": "接入端口名称:P311-会展中心-ATN910C-05-TPA2EG8-03", + "OSS侧事件流水号": "event-id-20260618-07721" + }, + "expect": { + "negotiation_needed": true, + "missing_slots": ["投诉分类"], + "fill_completes": true + } + }, + { + "id": "PLC-T04", + "channel": "fromText", + "intent": "缺OSS侧事件流水号,触发流水号协商并补齐闭环", + "input_text": "接入端口:P311-会展中心-ATN910C-05-TPA2EG8-03。故障现象:专线中断,业务完全不可用,柜面交易无法办理。问题发生时间:2026-06-18T09:00:00Z。", + "client_data": { + "任务对象": "接入端口名称:P311-会展中心-ATN910C-05-TPA2EG8-03", + "投诉分类": "专线中断", + "问题发生时间": "2026-06-18T09:00:00Z" + }, + "expect": { + "negotiation_needed": true, + "missing_slots": ["OSS侧事件流水号"], + "fill_completes": true + } + }, + { + "id": "PLC-T05", + "channel": "fromText", + "intent": "可选信息缺失(无时间无详情,必选齐全),不应触发协商", + "input_text": "接入端口:P781-珠江新城-PTN7900-23-TPA1EG24-17。故障现象:专线质差,网页打开缓慢。OSS侧事件流水号:event-id-20260602-08841。", + "expect": { + "negotiation_needed": false, + "missing_slots": [] + } + }, + { + "id": "PLC-T06", + "channel": "fromText", + "intent": "任务对象指代模糊('我们金融客户的那条专线',非端口/ID/名称形态),触发任务对象协商并补齐闭环", + "input_text": "我们金融客户的那条专线出问题了,专线质差,网页打开缓慢,视频卡顿。问题发生时间:2026-06-18T09:00:00Z。OSS侧事件流水号:event-id-20260618-07721。", + "client_data": { + "投诉分类": "专线质差", + "问题发生时间": "2026-06-18T09:00:00Z", + "OSS侧事件流水号": "event-id-20260618-07721" + }, + "expect": { + "negotiation_needed": true, + "missing_slots": ["任务对象"], + "fill_completes": true + } + }, + { + "id": "PLC-T07", + "channel": "fromText", + "intent": "字段错位(端口误写在现象描述里),抽取器应归位到任务对象,不触发协商", + "input_text": "故障现象:专线中断,出现问题的接入端口是P781-珠江新城-PTN7900-23-TPA1EG24-17,业务完全不可用。问题发生时间:2026-06-02T14:30:00Z。OSS侧事件流水号:event-id-20260602-08841。", + "expect": { + "negotiation_needed": false, + "missing_slots": [] + } + }, + { + "id": "PLC-T08", + "channel": "fromText", + "intent": "口语化完整(端口埋在长句中需归位,现象模糊需推断为质差),不应触发协商", + "input_text": "就是珠江新城那边PTN7900网元上P781-珠江新城-PTN7900-23-TPA1EG24-17这个接入端口带的专线,最近时好时坏,网速慢得很,客户体验受影响,OSS侧事件流水号:event-id-20260511-09013。", + "expect": { + "negotiation_needed": false, + "missing_slots": [] + } + }, + { + "id": "PLC-T09", + "channel": "fromText", + "intent": "任务上下文整体为空:bundled required 槽在生成期即被拦截,任务无法生成,协商不触发", + "input_text": "帮我看一下专线的问题。", + "expect": { + "negotiation_needed": false, + "missing_slots": [], + "generation_fails": true + } + }, + { + "id": "PLC-T10", + "channel": "fromText", + "intent": "负例补槽:缺投诉分类,补入违反枚举约束的值,二次校验应拒绝(测校验器对补槽值的把关)", + "input_text": "接入端口:P311-会展中心-ATN910C-05-TPA2EG8-03。OSS侧事件流水号:event-id-20260618-07721。", + "client_data": { + "任务对象": "接入端口名称:P311-会展中心-ATN910C-05-TPA2EG8-03", + "OSS侧事件流水号": "event-id-20260618-07721" + }, + "fill_override": { + "投诉分类": "搞不清楚什么问题" + }, + "expect": { + "negotiation_needed": true, + "missing_slots": ["投诉分类"], + "fill_completes": false + } + } + ] +} 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 new file mode 100644 index 00000000..2162077d --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/negotiation/eval/fromdata-api-suite.json @@ -0,0 +1,241 @@ +{ + "suite": "negotiation-fromdata-api-eval", + "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": [ + "任务对象", + "投诉分类", + "OSS侧事件流水号" + ], + "optional": [ + "问题发生时间", + "投诉详情" + ] + }, + "cases": [ + { + "id": "FD-P01", + "api": "propose", + "intent": "propose:请求补充全部三个必选槽位(任务对象+投诉分类+OSS流水号),items 为含义+样例说明,报文应逐字携带全部说明与关系句", + "input": { + "items": { + "任务对象": "专线业务对象标识,三选一:专线业务名称(如SZ_汇聚专线业务_100M_SPN)、专线业务标识(如5571d707-5aad-11ea-8629-286ed488cff3)、接入端口名称(如P781-珠江新城-PTN7900-23-TPA1EG24-17)", + "投诉分类": "投诉场景分类,取值专线中断或专线质差", + "OSS侧事件流水号": "OSS侧事件流水号,如event-id-20260602-08841" + }, + "relationship": "任务对象、投诉分类和OSS侧事件流水号三项均为必选,三项之间为AND关系;缺少任一项均无法启动诊断" + }, + "expect": { + "succeeds": true, + "template": "Negotiation-T/information-negotiation/propose/v1" + } + }, + { + "id": "FD-P02", + "api": "propose", + "intent": "propose:请求补充单必选槽位(任务对象),item 为含义+样例说明", + "input": { + "items": { + "任务对象": "专线业务对象标识,三选一:专线业务名称(如SZ_汇聚专线业务_100M_SPN)、专线业务标识(如5571d707-5aad-11ea-8629-286ed488cff3)、接入端口名称(如P781-珠江新城-PTN7900-23-TPA1EG24-17)" + } + }, + "expect": { + "succeeds": true, + "template": "Negotiation-T/information-negotiation/propose/v1" + } + }, + { + "id": "FD-P03", + "api": "propose", + "intent": "propose:请求补充单必选槽位(投诉分类),item 为含义+取值说明", + "input": { + "items": { + "投诉分类": "投诉场景分类,取值专线中断或专线质差" + } + }, + "expect": { + "succeeds": true, + "template": "Negotiation-T/information-negotiation/propose/v1" + } + }, + { + "id": "FD-P04", + "api": "propose", + "intent": "propose:请求补充单必选槽位(OSS侧事件流水号),item 为含义+样例说明", + "input": { + "items": { + "OSS侧事件流水号": "OSS侧事件流水号,如event-id-20260602-08841" + } + }, + "expect": { + "succeeds": true, + "template": "Negotiation-T/information-negotiation/propose/v1" + } + }, + { + "id": "FD-P05", + "api": "propose", + "intent": "propose:请求补充双必选槽位(任务对象+投诉分类)", + "input": { + "items": { + "任务对象": "专线业务对象标识,三选一:专线业务名称(如SZ_汇聚专线业务_100M_SPN)、专线业务标识(如5571d707-5aad-11ea-8629-286ed488cff3)、接入端口名称(如P781-珠江新城-PTN7900-23-TPA1EG24-17)", + "投诉分类": "投诉场景分类,取值专线中断或专线质差" + }, + "relationship": "任务对象和投诉分类均为必选,两项之间为AND关系;缺少任一项均无法启动诊断" + }, + "expect": { + "succeeds": true, + "template": "Negotiation-T/information-negotiation/propose/v1" + } + }, + { + "id": "FD-P06", + "api": "propose", + "intent": "propose:请求补充双必选槽位(任务对象+OSS侧事件流水号)", + "input": { + "items": { + "任务对象": "专线业务对象标识,三选一:专线业务名称(如SZ_汇聚专线业务_100M_SPN)、专线业务标识(如5571d707-5aad-11ea-8629-286ed488cff3)、接入端口名称(如P781-珠江新城-PTN7900-23-TPA1EG24-17)", + "OSS侧事件流水号": "OSS侧事件流水号,如event-id-20260602-08841" + }, + "relationship": "任务对象和OSS侧事件流水号均为必选,两项之间为AND关系;缺少任一项均无法启动诊断" + }, + "expect": { + "succeeds": true, + "template": "Negotiation-T/information-negotiation/propose/v1" + } + }, + { + "id": "FD-P07", + "api": "propose", + "intent": "propose:请求补充双必选槽位(投诉分类+OSS侧事件流水号)", + "input": { + "items": { + "投诉分类": "投诉场景分类,取值专线中断或专线质差", + "OSS侧事件流水号": "OSS侧事件流水号,如event-id-20260602-08841" + }, + "relationship": "投诉分类和OSS侧事件流水号均为必选,两项之间为AND关系;缺少任一项均无法启动诊断" + }, + "expect": { + "succeeds": true, + "template": "Negotiation-T/information-negotiation/propose/v1" + } + }, + { + "id": "FD-P08", + "api": "propose", + "intent": "propose:items 为空对象(数据边界;接口应拒绝零请求项——PR #151 修复验证用例)", + "input": { + "items": {} + }, + "expect": { + "succeeds": false + } + }, + { + "id": "FD-A01", + "api": "accept", + "intent": "accept:补齐全部必选字段(端口名+分类+流水号实值)", + "input": { + "items": { + "任务对象": "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "投诉分类": "专线中断", + "OSS侧事件流水号": "event-id-20260602-08841" + } + }, + "expect": { + "succeeds": true, + "template": "Negotiation-T/information-negotiation/accept-reject/v1", + "contains": [ + "Accept" + ] + } + }, + { + "id": "FD-A02", + "api": "accept", + "intent": "accept:补齐单必选字段(任务对象=专线业务标识形态)", + "input": { + "items": { + "任务对象": "5571d707-5aad-11ea-8629-286ed488cff3" + } + }, + "expect": { + "succeeds": true, + "template": "Negotiation-T/information-negotiation/accept-reject/v1", + "contains": [ + "Accept" + ] + } + }, + { + "id": "FD-A03", + "api": "accept", + "intent": "accept:补齐单必选字段(投诉分类)", + "input": { + "items": { + "投诉分类": "专线中断" + } + }, + "expect": { + "succeeds": true, + "template": "Negotiation-T/information-negotiation/accept-reject/v1", + "contains": [ + "Accept" + ] + } + }, + { + "id": "FD-A04", + "api": "accept", + "intent": "accept:补齐单必选字段(OSS侧事件流水号)", + "input": { + "items": { + "OSS侧事件流水号": "event-id-20260602-08841" + } + }, + "expect": { + "succeeds": true, + "template": "Negotiation-T/information-negotiation/accept-reject/v1", + "contains": [ + "Accept" + ] + } + }, + { + "id": "FD-R01", + "api": "reject", + "intent": "reject:单字段无法提供(任务对象,站点清单不可用),报文应含 Reject 结论、字段名与无法提供的原因", + "input": { + "items": { + "任务对象": "站点清单不可用,无法提供" + } + }, + "expect": { + "succeeds": true, + "template": "Negotiation-T/information-negotiation/accept-reject/v1", + "contains": [ + "Reject" + ] + } + }, + { + "id": "FD-R02", + "api": "reject", + "intent": "reject:双字段无法提供(任务对象+OSS侧事件流水号),报文应含 Reject 结论与两项名称+原因", + "input": { + "items": { + "任务对象": "站点清单不可用,无法提供", + "OSS侧事件流水号": "工单系统不可访问,无法提供" + } + }, + "expect": { + "succeeds": true, + "template": "Negotiation-T/information-negotiation/accept-reject/v1", + "contains": [ + "Reject" + ] + } + } + ] +} 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 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..480d6430 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/negotiation/scenario.json @@ -0,0 +1,46 @@ +{ + "scenario": "private-line complaint diagnosis with negotiation", + "task_schema": { + "type": "object", + "properties": { + "任务对象": { + "type": "string", + "description": "必选。使用专线业务名称、专线业务标识、接入端口名称三种方式之一标识被投诉的专线业务对象,三种方式之间为OR关系", + "examples": [ + "接入端口名称:P781-珠江新城-PTN7900-23-TPA1EG24-17" + ] + }, + "任务上下文": { + "type": "string", + "description": "必选。投诉分类和OSS侧事件流水号为必选;问题发生时间和投诉详情为可选", + "examples": [ + "投诉分类:专线中断。OSS侧事件流水号:fault-id-1-017-20230511-09013" + ] + } + }, + "required": [ + "任务对象", + "任务上下文" + ] + }, + "missing_params": { + "任务对象": "P781-珠江新城-PTN7900-23-TPA1EG24-17", + "任务上下文": "2. 问题发生时间:2026-05-11T08:21:46Z\n4. 投诉详情:深圳访问广州响应延迟骤升,交易接口频繁报连接超时" + }, + "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": "所列字段均为必选,多个字段之间为AND关系;缺少任一字段均无法启动诊断", + "from_text_propose_prefix": "请提供以下缺失信息:", + "from_text_accept_prefix": "同意补充以下信息:", + "from_text_accept_suffix": "信息已完整,可以启动诊断。" + }, + "diagnosis": { + "result_line": "1. 诊断结果:成功", + "detail_line": "2. 诊断结果详情:{params}", + "advice_line": "3. 修复建议:检查接入端口物理连接和光模块状态,必要时恢复供电或重新开启端口" + } +} 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..cc342269 --- /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": "NegotiationParamExtractionException", + "expected_code": "negotiation.invalid_input", + "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": "NegotiationParamExtractionException", + "expected_code": "negotiation.invalid_input", + "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": "NegotiationParamExtractionException", + "expected_code": "negotiation.invalid_input", + "description": "接受校验接口 prompt 为空" + }, + { + "id": "V-ACCEPT-NULL-PROMPT", + "api": "validate_accept", + "input": null, + "template": "ending", + "performative": "ACCEPT", + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation.invalid_input", + "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": "NegotiationParamExtractionException", + "expected_code": "negotiation.invalid_input", + "description": "拒绝校验接口 prompt 为空" + }, + { + "id": "V-REJECT-NULL-PROMPT", + "api": "validate_reject", + "input": null, + "template": "ending", + "performative": "REJECT", + "expected_exception": "NegotiationParamExtractionException", + "expected_code": "negotiation.invalid_input", + "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..81746a51 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/private-line-complaint-negotiation/evaluation/business-abnormal-cases.json @@ -0,0 +1,98 @@ +[ + { + "id": "B-G-PROPOSE-NO-ITEM", + "api": "generate_propose", + "input": "当前只需要继续处理这条专线投诉,请直接给出处理建议,不需要列出待补充信息。", + "description": "发起协商文本没有明确的待补充信息项", + "modelDependent": true, + "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] + }, + { + "id": "B-G-PROPOSE-FREEFORM", + "api": "generate_propose", + "input": "请用一段自然语言概述对端需要提供的资料,不要使用信息项列表或逐项结构。", + "description": "发起协商文本明确要求非模板列表结构", + "modelDependent": true, + "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] + }, + { + "id": "B-G-ACCEPT-NO-RESULT", + "api": "generate_accept", + "input": "接受本次协商,后续再补充具体信息。", + "description": "接受文本没有提供任何结果内容", + "modelDependent": true, + "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] + }, + { + "id": "B-G-ACCEPT-VAGUE-RESULT", + "api": "generate_accept", + "input": "同意,信息按之前说的处理即可。", + "description": "接受文本只有结论,没有可渲染的逐项结果", + "modelDependent": true, + "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] + }, + { + "id": "B-G-REJECT-NO-REASON", + "api": "generate_reject", + "input": "拒绝本次协商。", + "description": "拒绝文本没有说明拒绝原因或结果项", + "modelDependent": true, + "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] + }, + { + "id": "B-G-REJECT-FREEFORM", + "api": "generate_reject", + "input": "这次不方便提供资料,请用一段话说明即可,不需要逐项列出拒绝内容。", + "description": "拒绝文本要求非逐项结果结构", + "modelDependent": true, + "expectedCodes": ["negotiation.field_missing", "negotiation.content_extract_failed"] + }, + { + "id": "B-V-PROPOSE-MISSING-SECTION", + "api": "validate_propose", + "input": "## 信息协商\n请继续处理当前投诉。", + "description": "发起报文缺少所需信息项段落", + "modelDependent": true, + "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] + }, + { + "id": "B-V-PROPOSE-FREEFORM-SECTION", + "api": "validate_propose", + "input": "## 信息协商\n请根据<所需信息项>补充相关内容。\n\n## 所需信息项\n请提供相关资料即可。", + "description": "发起报文的所需信息项不是逐项列表", + "modelDependent": true, + "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] + }, + { + "id": "B-V-ACCEPT-MISSING-RESULT", + "api": "validate_accept", + "input": "## 信息协商结果\nAccept", + "description": "接受报文缺少结果内容段落", + "modelDependent": true, + "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] + }, + { + "id": "B-V-ACCEPT-FREEFORM-RESULT", + "api": "validate_accept", + "input": "## 信息协商结果\nAccept\n\n## 信息协商结果内容\n相关信息保持不变。", + "description": "接受结果内容不是逐项信息", + "modelDependent": true, + "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] + }, + { + "id": "B-V-REJECT-MISSING-RESULT", + "api": "validate_reject", + "input": "## 信息协商结果\nReject", + "description": "拒绝报文缺少结果内容段落", + "modelDependent": true, + "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] + }, + { + "id": "B-V-REJECT-FREEFORM-RESULT", + "api": "validate_reject", + "input": "## 信息协商结果\nReject\n\n## 信息协商结果内容\n由于当前情况不允许,暂时无法提供。", + "description": "拒绝结果内容没有按请求信息项逐项说明", + "modelDependent": true, + "expectedCodes": ["negotiation.rule_violation", "negotiation.semantic_rejected"] + } +] 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..3fb9064e --- /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":"请发起信息协商,补充接入端口名称和投诉分类;端口可为物理或逻辑端口,分类限专线中断或专线质差。","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/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..bb2cca7c --- /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= +A2AT_LLM_API_KEY= +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..726f5e6b --- /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/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..7f34e743 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/client/input-with-data.json @@ -0,0 +1,48 @@ +{ + "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/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..611280c8 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/client/schema.json @@ -0,0 +1,42 @@ +{ + "$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": "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. 业务抢通方案执行失败原因,业务抢通方案执行结果为“失败”时,需要填写此信息(可选)。" + } + }, + "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..950cf092 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/en-US/content_validation.json @@ -0,0 +1,9 @@ +{ + "semantic_verdict": true, + "errors": [], + "params": { + "topic": "Service Recovery Event", + "subscriptionCondition": "", + "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 new file mode 100644 index 00000000..7f5144d0 --- /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); 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 new file mode 100644 index 00000000..4d8b6b16 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/mock_responses/zh-CN/content_validation.json @@ -0,0 +1,9 @@ +{ + "semantic_verdict": true, + "errors": [], + "params": { + "topic": "业务抢通事件", + "subscriptionCondition": "", + "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 new file mode 100644 index 00000000..60d89fd4 --- /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. 业务抢通方案执行结束时间(可选);9. 业务抢通方案执行结果,取值范围:成功、失败(可选);10. 业务抢通方案执行失败原因,执行结果为失败时必填(可选)。" + }, + "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..ead6da10 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "topic": { + "type": "string", + "description": "订阅主题(必选)。订阅的事件主题名称,例如:业务抢通事件。", + "examples": ["业务抢通事件"] + }, + "subscriptionCondition": { + "type": "string", + "description": "订阅条件(可选)。待订阅的条件描述,例如:子网名称为xx子网。订阅可以不带条件,此时该参数输出空字符串\"\"而非null。", + "examples": [ + "子网名称:xx子网" + ], + "x-a2at-value-constraint": "订阅条件为子网名称。订阅可以不带条件,此时该参数为空。" + }, + "notificationDataFormat": { + "type": "string", + "description": "上报通知数据格式(必选)。待上报的通知数据格式描述,用于定义业务抢通事件的数据结构。", + "examples": [ + "业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、业务抢通方案名称、业务抢通方案详情。" + ] + } + }, + "required": [ + "topic", + "notificationDataFormat" + ] +} \ No newline at end of file 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/client/client.env b/a2a-t-sample/src/main/resources/sample/subscribe-incident/client/client.env similarity index 77% rename from a2a-t-sample/src/main/resources/sample/client/client.env rename to a2a-t-sample/src/main/resources/sample/subscribe-incident/client/client.env index 9e7d640f..e5a71c5f 100644 --- a/a2a-t-sample/src/main/resources/sample/client/client.env +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/client/client.env @@ -1,12 +1,12 @@ # Copy this file to client.env before starting the a2a-java v1.0.0.Beta1 HTTP+JSON/REST sample client. -# Configure any OpenAI-compatible LLM endpoint before running the sample client. +# Configure any OpenAI LLM endpoint before running the sample client. A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=classpath A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible +A2AT_LLM_PROVIDER=openai A2AT_LLM_MODEL=deepseek-chat A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY=${your_api_key} +A2AT_LLM_API_KEY= A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory REGISTRY_CENTER_HOST=127.0.0.1 REGISTRY_CENTER_PORT=5001 diff --git a/a2a-t-sample/src/main/resources/sample/client/scenario.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/client/scenario.json similarity index 100% rename from a2a-t-sample/src/main/resources/sample/client/scenario.json rename to a2a-t-sample/src/main/resources/sample/subscribe-incident/client/scenario.json diff --git a/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/scenario_recognition.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/scenario_recognition.json new file mode 100644 index 00000000..31fd38a7 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/scenario_recognition.json @@ -0,0 +1,5 @@ +{ + "matched": true, + "scenario_code": "subscribe-incident", + "error_message": null +} diff --git a/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/semantic_validation.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/semantic_validation.json new file mode 100644 index 00000000..8417d943 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/semantic_validation.json @@ -0,0 +1,4 @@ +{ + "passed": true, + "errors": [] +} diff --git a/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/slot_extraction.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/slot_extraction.json new file mode 100644 index 00000000..ea329198 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/en-US/slot_extraction.json @@ -0,0 +1,8 @@ +{ + "slots": { + "notification_topic": "Incident", + "subscribe_condition": "critical ETH-LOS fault", + "notification_data_format": "DataPart" + }, + "slot_errors": [] +} diff --git a/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/scenario_recognition.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/scenario_recognition.json new file mode 100644 index 00000000..31fd38a7 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/scenario_recognition.json @@ -0,0 +1,5 @@ +{ + "matched": true, + "scenario_code": "subscribe-incident", + "error_message": null +} diff --git a/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/semantic_validation.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/semantic_validation.json new file mode 100644 index 00000000..8417d943 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/semantic_validation.json @@ -0,0 +1,4 @@ +{ + "passed": true, + "errors": [] +} diff --git a/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/slot_extraction.json b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/slot_extraction.json new file mode 100644 index 00000000..bcb231c9 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/mock_responses/zh-CN/slot_extraction.json @@ -0,0 +1,8 @@ +{ + "slots": { + "通知主题": "Incident", + "订阅条件": "订阅级别为critical的ETH-LOS的故障", + "上报通知数据格式": "DataPart" + }, + "slot_errors": [] +} diff --git a/a2a-t-sample/src/main/resources/sample/server/server.env b/a2a-t-sample/src/main/resources/sample/subscribe-incident/server/server.env similarity index 59% rename from a2a-t-sample/src/main/resources/sample/server/server.env rename to a2a-t-sample/src/main/resources/sample/subscribe-incident/server/server.env index b7d60fbf..ee723261 100644 --- a/a2a-t-sample/src/main/resources/sample/server/server.env +++ b/a2a-t-sample/src/main/resources/sample/subscribe-incident/server/server.env @@ -1,12 +1,12 @@ # Copy this file to server.env before starting the a2a-java v1.0.0.Beta1 HTTP+JSON/REST sample server. -# Configure any OpenAI-compatible LLM endpoint before running the sample server. +# Configure any OpenAI LLM endpoint before running the sample server. A2AT_LANGUAGE=zh-CN A2AT_PROMPT_SOURCE_TYPE=local_file -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=../../../../../../a2a-t-resources/src/main/resources/prompt_resources -A2AT_LLM_PROVIDER=openai_compatible +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=${your_api_key} +A2AT_LLM_API_KEY= A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory A2AT_SAMPLE_HOST=127.0.0.1 A2AT_SAMPLE_PORT=26335 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 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..4eceba63 --- /dev/null +++ b/a2a-t-sample/src/main/resources/sample/task_t/private-line-complaint-samples.json @@ -0,0 +1,372 @@ +{ + "semanticsSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "portName": { + "type": "string", + "description": "业务字段:接入端口名称,唯一标识被投诉的专线对象,物理端口格式为:网元名称-单板号-单板型号-端口号;逻辑端口在末尾追加(cvlan=接入vlanid)", + "examples": [ + "P781-KXVN-PTN7900-23-TPA1EG24-17", + "P781-QMBR-PTN7900-5-TPA1EG24-09(cvlan=100)" + ] + }, + "complaintScenario": { + "type": "string", + "description": "业务字段:投诉分类场景,专线中断或专线质差", + "enum": [ + "专线中断", + "专线质差" + ], + "examples": [ + "专线中断" + ] + }, + "faultStartTime": { + "type": "string", + "description": "业务字段:问题发生时间", + "examples": [ + "2026-05-11T08:21:46Z" + ] + }, + "ticketNo": { + "type": "string", + "description": "业务字段:OSS 侧受理的投诉工单或事件流水号,供诊断回溯关联,必填字段", + "examples": [ + "event-id-20260511-09013" + ] + }, + "faultDetailText": { + "type": "string", + "description": "业务字段:用户对故障现象的自由描述,可包含 OSS 侧预处理信息,用于指导诊断方向", + "examples": [ + "从5月11号早上8点半开始,深圳访问广州的响应时延从平均12ms骤升至320ms" + ] + } + }, + "required": [ + "portName", + "complaintScenario" + ] + }, + "validationSchema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "accessPort": { + "type": "string", + "description": "接入端口名称,唯一标识被投诉的专线对象,必填,须与投诉对象一致", + "examples": [ + "P781-KXVN-PTN7900-23-TPA1EG24-17", + "P781-QMBR-PTN7900-5-TPA1EG24-09(cvlan=100)" + ] + }, + "bizScenario": { + "type": "string", + "description": "投诉分类场景,必填,仅允许取值:专线中断、专线质差", + "enum": [ + "专线中断", + "专线质差" + ], + "examples": [ + "专线质差" + ] + }, + "faultTime": { + "type": "string", + "description": "问题发生时间", + "examples": [ + "2026-05-11T08:21:46Z" + ] + }, + "eventSerialNo": { + "type": "string", + "description": "OSS 侧受理的投诉工单或事件流水号,必填", + "examples": [ + "event-id-20260511-09013" + ] + }, + "faultDetail": { + "type": "string", + "description": "投诉/故障现象详情描述", + "examples": [ + "从5月11号早上8点半开始,深圳访问广州的响应时延从平均12ms骤升至320ms" + ] + } + }, + "required": [ + "accessPort", + "bizScenario" + ] + }, + "samples": [ + { + "name": "text-private-line-quality", + "kind": "text", + "text": "帮我创建专线投诉诊断任务,P781-KXVN-PTN7900-23-TPA1EG24-17 这个端口,客户报的是专线质差,从2026年5月11号早上8点半开始,深圳访问广州的核心系统非常慢,时延从12ms飙到320ms,柜面和手机银行老是报连接超时,OSS流水号是event-id-20260511-09013。", + "expected": { + "accessPort": "P781-KXVN-PTN7900-23-TPA1EG24-17", + "bizScenario": "专线质差", + "faultTime": "5月11号", + "eventSerialNo": "event-id-20260511-09013", + "faultDetail": "320ms" + } + }, + { + "name": "text-logical-port-interruption", + "kind": "text", + "text": "创建专线业务投诉诊断,P781-QMBR-PTN7900-5-TPA1EG24-09(cvlan=100) 这条链路,2026年5月16号中午12点10分以后深圳到上海专线完全中断,核心系统都访问不了,分类是专线中断,流水号fault-id-1-017-20260516-11234。", + "expected": { + "accessPort": "P781-QMBR-PTN7900-5-TPA1EG24-09(cvlan=100)", + "bizScenario": "专线中断", + "faultTime": "5月16号", + "eventSerialNo": "fault-id-1-017-20260516-11234", + "faultDetail": "完全中断" + } + }, + { + "name": "text-port-quality-jitter", + "kind": "text", + "text": "麻烦看下 P781-PTZS-PTN7900-7-TPA1EG24-21 这条专线,5月22号下午两点多起时延跳得厉害,丢包最高到15%,视频会议一直卡,分类是专线质差,流水号event-id-20260522-00812。", + "expected": { + "accessPort": "P781-PTZS-PTN7900-7-TPA1EG24-21", + "bizScenario": "专线质差", + "faultTime": "5月22号", + "eventSerialNo": "event-id-20260522-00812", + "faultDetail": "丢包" + } + }, + { + "name": "text-port-interruption-route", + "kind": "text", + "text": "创建专线投诉诊断,P781-FDWC-PTN7900-9-TPA1EG24-31 这条专线,6月3号早上9点40分起彻底不通,上海到北京核心系统全部访问不了,客户报的是专线中断,流水号event-id-20260603-02341。", + "expected": { + "accessPort": "P781-FDWC-PTN7900-9-TPA1EG24-31", + "bizScenario": "专线中断", + "faultTime": "6月3号", + "eventSerialNo": "event-id-20260603-02341", + "faultDetail": "彻底不通" + } + }, + { + "name": "text-port-quality-latency", + "kind": "text", + "text": "帮我诊断下 P781-LJVK-PTN7900-4-TPA1EG24-11 端口的问题,6月8号下午3点多开始网络变差,深圳到上海时延忽高忽低最高接近500ms,视频会议直接开不了,分类专线质差,流水号event-id-20260608-01102。", + "expected": { + "accessPort": "P781-LJVK-PTN7900-4-TPA1EG24-11", + "bizScenario": "专线质差", + "faultTime": "6月8号", + "eventSerialNo": "event-id-20260608-01102", + "faultDetail": "时延" + } + }, + { + "name": "text-logical-port-interruption-vlan", + "kind": "text", + "text": "看下 P781-NRQF-PTN7900-6-TPA1EG24-27(cvlan=200) 这条链路,2026年6月12号晚上8点起就彻底连不上了,柜面交易全部失败,客户说是专线中断,流水号fault-id-9-023-20260612-04578。", + "expected": { + "accessPort": "P781-NRQF-PTN7900-6-TPA1EG24-27(cvlan=200)", + "bizScenario": "专线中断", + "faultTime": "6月12号", + "eventSerialNo": "fault-id-9-023-20260612-04578", + "faultDetail": "连不上" + } + }, + { + "name": "data-port-quality", + "kind": "data", + "data": { + "portName": "P781-YSXR-PTN7900-2-TPA1EG24-03", + "complaintScenario": "", + "faultStartTime": "2026-05-11T08:21:46Z", + "ticketNo": "event-id-20260511-09013", + "faultDetailText": "深圳访问广州的响应时延从平均12ms骤升至320ms、峰值达480ms,访问广州机房的核心交易系统响应非常慢,柜面和手机银行的交易接口频繁报\"连接超时\",正常业务操作要等十几秒甚至半分钟才能返回结果;OSS侧预处理:该时段无割接与网元告警,初步判断链路存在拥塞或丢包。" + }, + "expected": { + "accessPort": "P781-YSXR-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-QMBR-PTN7900-5-TPA1EG24-09(cvlan=100)", + "complaintScenario": "专线中断", + "faultStartTime": "2026-05-16T12:10:00Z", + "ticketNo": "fault-id-1-017-20260516-11234", + "faultDetailText": "深圳前海机房至上海数据中心的专线业务完全中断,业务流量归零,核心交易系统无法访问,影响柜面及手机银行交易;对端网关ping丢包率100%,测试光功率正常、端口无物理告警,OSS预处理无割接操作,怀疑中间路由或设备转发存在问题。" + }, + "expected": { + "accessPort": "P781-QMBR-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-PTZS-PTN7900-7-TPA1EG24-21", + "complaintScenario": "专线质差", + "faultStartTime": "2026-05-22T14:05:00Z", + "ticketNo": "event-id-20260522-00812", + "faultDetailText": "晚间高峰,专线时延抖动明显,ping对端时延在60ms~200ms间波动,平均丢包率约5%、峰值达15%,视频会议频繁卡顿、语音断续,核心业务交易超时;OSS未见割接告警,初步判断城域网链路拥塞并伴路由震荡。" + }, + "expected": { + "accessPort": "P781-PTZS-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-GHMT-PTN7900-1-TPA1EG24-08(cvlan=300)", + "complaintScenario": "专线中断", + "faultStartTime": "2026-06-03T09:40:00Z", + "ticketNo": "event-id-20260603-02341", + "faultDetailText": "深圳云浮机房至北京数据中心的专线业务完全中断,业务流量归零,核心交易系统无法访问,影响柜面及手机银行交易;对端网关ping丢包率100%,两端光功率正常、端口无物理告警,OSS预处理无割接操作,怀疑中间路由或设备转发异常。" + }, + "expected": { + "accessPort": "P781-GHMT-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-CWDV-PTN7900-6-TPA1EG24-13", + "complaintScenario": "专线质差", + "faultStartTime": "2026-06-08T15:20:00Z", + "ticketNo": "event-id-20260608-01102", + "faultDetailText": "晚间时段,专线时延剧烈波动,ping对端时延在80ms~500ms间跳动、峰值达480ms,平均丢包率约8%、峰值15%,视频会议频繁卡顿、语音断续,核心业务交易超时;OSS未见割接告警,初步判断城域网链路拥塞并伴路由震荡。" + }, + "expected": { + "accessPort": "P781-CWDV-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-TJPN-PTN7900-8-TPA1EG24-15", + "complaintScenario": "专线质差", + "faultStartTime": "2026-06-18T10:05:00Z", + "ticketNo": "event-id-20260618-03342", + "faultDetailText": "深圳访问香港核心系统的响应时延从平均8ms骤升至180ms、峰值达250ms,柜面及手机银行接口频繁报\"连接超时\",正常操作需等待数十秒;OSS侧预处理该时段无割接与网元告警,初步判断链路存在拥塞或丢包。" + }, + "expected": { + "accessPort": "P781-TJPN-PTN7900-8-TPA1EG24-15", + "bizScenario": "专线质差", + "faultTime": "2026-06-18T10:05:00Z", + "eventSerialNo": "event-id-20260618-03342", + "faultDetail": "时延" + } + }, + { + "name": "text-optional-slots-missing", + "kind": "text", + "text": "麻烦看下 P781-BZHK-PTN7900-3-TPA1EG24-19 这条专线,客户报的是专线质差,视频会议频繁卡顿,时延抖动快到300ms,帮我创建投诉诊断任务。", + "expected": { + "accessPort": "P781-BZHK-PTN7900-3-TPA1EG24-19", + "bizScenario": "专线质差", + "faultDetail": "卡顿" + } + }, + { + "name": "data-optional-slots-missing", + "kind": "data", + "data": { + "portName": "P781-RMQF-PTN7900-7-TPA1EG24-18", + "complaintScenario": "专线中断", + "faultDetailText": "专线业务完全中断,核心交易系统无法访问,对端网关ping丢包率100%,流量归零。" + }, + "expected": { + "accessPort": "P781-RMQF-PTN7900-7-TPA1EG24-18", + "bizScenario": "专线中断", + "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-QMBR-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-scenario-and-serial", + "kind": "text", + "text": "帮忙看下 P781-KXVN-PTN7900-23-TPA1EG24-17 这条专线,5月11号早上8点半,帮我诊断一下。" + }, + { + "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-YSXR-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-PTZS-PTN7900-7-TPA1EG24-21", + "complaintScenario": "专线掉线", + "faultStartTime": "2026-05-22T14:05:00Z", + "ticketNo": "event-id-20260522-00812", + "faultDetailText": "专线时延抖动明显,平均丢包率约5%、峰值达15%,视频会议频繁卡顿。" + } + }, + { + "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/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java new file mode 100644 index 00000000..01cc743a --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeApiSymmetryTest.java @@ -0,0 +1,96 @@ +package net.openan.a2at.sample.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.Test; + +/** + * Guards the negotiation content-layer API surface of both high-level facades. + * + *

      The client and the server facade expose the negotiation generation, query and validation APIs as one symmetric + * surface: identical method names, parameter types and return types. This test fails when one facade drifts from the + * other. + */ +class NegotiationFacadeApiSymmetryTest { + + private static final List NEGOTIATION_API_METHODS = List.of( + "generateNegotiationProposePromptFromData", + "generateNegotiationAcceptPromptFromData", + "generateNegotiationRejectPromptFromData", + "generateNegotiationAbortPromptFromData", + "generateNegotiationProposePromptFromText", + "generateNegotiationAcceptPromptFromText", + "generateNegotiationRejectPromptFromText", + "generateNegotiationAbortPromptFromText", + "validateProposePromptAndDataFilling", + "validateAcceptPromptAndDataFilling", + "validateRejectPromptAndDataFilling", + "validateAbortPromptAndDataFilling"); + + private static final List REMOVED_NEGOTIATION_QUERY_METHODS = + List.of("getNegotiationPrompts", "getNegotiationPrompt"); + + private static final List CROSS_EXTENSION_QUERY_METHODS = List.of("getPrompts", "getPrompt"); + + @Test + void bothFacadesExposeTheSameTwelveNegotiationApiMethods() { + Map clientSurface = negotiationApiSurface(A2ATClient.class); + Map serverSurface = negotiationApiSurface(A2ATServer.class); + + assertEquals(new HashSet<>(NEGOTIATION_API_METHODS), clientSurface.keySet()); + assertEquals(clientSurface, serverSurface); + } + + @Test + void neitherFacadeExposesTheRemovedNegotiationQueryMethods() { + for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { + for (String removedMethod : REMOVED_NEGOTIATION_QUERY_METHODS) { + boolean present = Arrays.stream(facade.getMethods()) + .anyMatch(method -> method.getName().equals(removedMethod)); + assertFalse( + present, + facade.getSimpleName() + " must not expose the removed " + removedMethod + " query method"); + } + } + } + + @Test + void bothFacadesExposeTheSameCrossExtensionQueryMethods() { + assertEquals( + new HashSet<>(CROSS_EXTENSION_QUERY_METHODS), + Arrays.stream(A2ATClient.class.getMethods()) + .filter(method -> CROSS_EXTENSION_QUERY_METHODS.contains(method.getName())) + .map(Method::getName) + .collect(Collectors.toSet())); + assertEquals( + Arrays.stream(A2ATClient.class.getMethods()) + .filter(method -> CROSS_EXTENSION_QUERY_METHODS.contains(method.getName())) + .map(NegotiationFacadeApiSymmetryTest::methodSignature) + .collect(Collectors.toSet()), + Arrays.stream(A2ATServer.class.getMethods()) + .filter(method -> CROSS_EXTENSION_QUERY_METHODS.contains(method.getName())) + .map(NegotiationFacadeApiSymmetryTest::methodSignature) + .collect(Collectors.toSet())); + } + + private static Map negotiationApiSurface(Class facade) { + return Arrays.stream(facade.getMethods()) + .filter(method -> NEGOTIATION_API_METHODS.contains(method.getName())) + .collect(Collectors.toMap(Method::getName, NegotiationFacadeApiSymmetryTest::methodSignature)); + } + + private static String methodSignature(Method method) { + String parameters = + Arrays.stream(method.getParameterTypes()).map(Class::getName).collect(Collectors.joining(",")); + return parameters + "->" + method.getReturnType().getName(); + } +} 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 new file mode 100644 index 00000000..9d699137 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/api/NegotiationFacadeOutputSymmetryTest.java @@ -0,0 +1,284 @@ +package net.openan.a2at.sample.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.FeasibilityEndingContent; +import net.openan.a2at.sdk.negotiation.content.FeasibilityProposeContent; +import net.openan.a2at.sdk.negotiation.content.InformationEndingContent; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAction; +import net.openan.a2at.sdk.negotiation.content.NegotiationConclusion; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.content.TargetEndingContent; +import net.openan.a2at.sdk.negotiation.content.TargetProposeContent; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies the output symmetry of the negotiation content layer across the client and the server facade. + * + *

      Both facades expose the twelve negotiation API methods as one symmetric surface and produce, for identical inputs, + * identical generation results: the whole MetadataContent record and the metadata map built from it are equal on both + * sides. The inputs are the fixed golden fixture inputs of the negotiation module, so this test also proves that both + * facades are wired to the same built-in resources. + */ +class NegotiationFacadeOutputSymmetryTest { + + private static final String TEST_MOCK_PROVIDER = "test-symmetry-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + private static final List NEGOTIATION_API_METHODS = List.of( + "generateNegotiationProposePromptFromData", + "generateNegotiationAcceptPromptFromData", + "generateNegotiationRejectPromptFromData", + "generateNegotiationAbortPromptFromData", + "generateNegotiationProposePromptFromText", + "generateNegotiationAcceptPromptFromText", + "generateNegotiationRejectPromptFromText", + "generateNegotiationAbortPromptFromText", + "validateProposePromptAndDataFilling", + "validateAcceptPromptAndDataFilling", + "validateRejectPromptAndDataFilling", + "validateAbortPromptAndDataFilling"); + + @TempDir + Path tempDir; + + @Test + void bothFacadesExposeTheSameTwelveMethodSignatures() { + Map clientSurface = negotiationApiSurface(A2ATClient.class); + Map serverSurface = negotiationApiSurface(A2ATServer.class); + + assertEquals(NEGOTIATION_API_METHODS.stream().sorted().toList(), sortedKeys(clientSurface)); + assertEquals(clientSurface, serverSurface); + } + + @Test + void bothFacadesProduceIdenticalMetadataContentForTheSameInput() throws IOException { + for (String language : List.of("zh-CN", "en-US")) { + A2ATClient client = new A2ATClient(envFile(language, "client.env")); + A2ATServer server = new A2ATServer(envFile(language, "server.env")); + + for (SymmetryCase symmetryCase : symmetryCases()) { + MetadataContent clientResult = symmetryCase.generate( + client::generateNegotiationProposePromptFromData, + client::generateNegotiationAcceptPromptFromData, + client::generateNegotiationRejectPromptFromData); + MetadataContent serverResult = symmetryCase.generate( + server::generateNegotiationProposePromptFromData, + server::generateNegotiationAcceptPromptFromData, + server::generateNegotiationRejectPromptFromData); + + assertNotNull(clientResult.promptText()); + assertEquals(clientResult, serverResult, "case " + symmetryCase.label() + " [" + language + "]"); + assertEquals(clientResult.buildMetadataContent(), serverResult.buildMetadataContent()); + assertEquals(symmetryCase.templateUri(), clientResult.templateUri()); + assertEquals( + symmetryCase.performative(), + clientResult.negotiationContext().performative(), + "stamped performative of case " + symmetryCase.label() + " [" + language + "]"); + } + } + } + + private Path envFile(String language, String fileName) throws IOException { + Path envFile = tempDir.resolve(language + "-" + fileName); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=%s + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-symmetry-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(language)); + return envFile; + } + + private static List sortedKeys(Map surface) { + return surface.keySet().stream().sorted().collect(Collectors.toList()); + } + + private static Map negotiationApiSurface(Class facade) { + Map surface = new LinkedHashMap<>(); + for (Method method : facade.getMethods()) { + if (NEGOTIATION_API_METHODS.contains(method.getName())) { + String parameters = Arrays.stream(method.getParameterTypes()) + .map(Class::getName) + .collect(Collectors.joining(",")); + surface.put( + method.getName(), + parameters + "->" + method.getReturnType().getName()); + } + } + return surface; + } + + /** + * Builds the nine golden fixture cases of the negotiation module: one fixed typed content, context and template URI + * per negotiation type and performative, identical on the client and the server side. + */ + private static List symmetryCases() { + return List.of( + new SymmetryCase( + "information_propose", + NegotiationPerformative.PROPOSE, + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI, + new NegotiationProposeData( + new NegotiationContext( + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent( + List.of( + new NegotiationItem( + "ran-energy-saving area information", "e.g. Songshan Lake"), + new NegotiationItem("VLANId", null)), + "OR"))), + new SymmetryCase( + "target_propose", + NegotiationPerformative.PROPOSE, + StandardTemplates.TARGET_NEGOTIATION_PROPOSE_URI, + new NegotiationProposeData( + new NegotiationContext( + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5, NegotiationPerformative.PROPOSE), + new TargetProposeContent( + "Clarify the intent of the ran-energy-saving task.", + List.of(new NegotiationItem("task intent", "ran-energy-saving optimization")), + null, + List.of(new NegotiationItem("area", "which site is covered")), + null))), + new SymmetryCase( + "feasibility_propose", + NegotiationPerformative.PROPOSE, + StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE_URI, + new NegotiationProposeData( + new NegotiationContext( + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, NegotiationPerformative.PROPOSE), + new FeasibilityProposeContent( + "Please assess the adjusted rate target.", + NegotiationAction.REQUEST_FEASIBILITY_EVALUATION, + List.of(new NegotiationItem("adjusted target", "rate lowered to 2Mbps")), + null, + null))), + endingCase( + "information_accept", + NegotiationPerformative.ACCEPT, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, + new InformationEndingContent( + NegotiationConclusion.ACCEPT, + List.of(new NegotiationItem("area information", "Songshan Lake")))), + endingCase( + "target_accept", + NegotiationPerformative.ACCEPT, + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI, + new TargetEndingContent(NegotiationConclusion.ACCEPT, "The confirmed intent.", null)), + endingCase( + "feasibility_accept", + NegotiationPerformative.ACCEPT, + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, + new FeasibilityEndingContent(NegotiationConclusion.ACCEPT, "The target is achievable.")), + endingCase( + "information_reject", + NegotiationPerformative.REJECT, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI, + new InformationEndingContent( + NegotiationConclusion.REJECT, + List.of(new NegotiationItem("area information", "not available")))), + endingCase( + "target_reject", + NegotiationPerformative.REJECT, + StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI, + new TargetEndingContent(NegotiationConclusion.REJECT, null, "The intent is unclear.")), + endingCase( + "feasibility_reject", + NegotiationPerformative.REJECT, + StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI, + new FeasibilityEndingContent(NegotiationConclusion.REJECT, "The target is not achievable."))); + } + + private static SymmetryCase endingCase( + String label, NegotiationPerformative performative, String templateUri, NegotiationEndingContent content) { + return new SymmetryCase( + label, + performative, + templateUri, + new NegotiationEndingData( + new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 2, 5, performative), content)); + } + + /** One symmetry case: a fixed input addressed to one of the three from-data generation methods. */ + private record SymmetryCase(String label, NegotiationPerformative performative, String templateUri, Object data) { + + MetadataContent generate(ProposeGenerator propose, EndingGenerator accept, EndingGenerator reject) { + return switch (performative) { + case PROPOSE -> propose.generate((NegotiationProposeData) data, templateUri); + case ACCEPT -> accept.generate((NegotiationEndingData) data, templateUri); + case REJECT -> reject.generate((NegotiationEndingData) data, templateUri); + case ABORT -> throw new IllegalArgumentException("The symmetry cases carry no abort performative."); + }; + } + } + + @FunctionalInterface + private interface ProposeGenerator { + + MetadataContent generate(NegotiationProposeData data, String templateUri); + } + + @FunctionalInterface + private interface EndingGenerator { + + MetadataContent generate(NegotiationEndingData data, String templateUri); + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCaptureTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCaptureTest.java new file mode 100644 index 00000000..94e4e4f2 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzReasoningCaptureTest.java @@ -0,0 +1,289 @@ +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.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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import net.openan.a2at.sample.authz_policy.AuthzReasoningCapture.StageCapture; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class AuthzReasoningCaptureTest { + + 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"; + + @AfterEach + void clearProperty() { + System.clearProperty("authz.reasoning"); + } + + private static String loadResource(String path) { + try (InputStream in = AuthzReasoningCaptureTest.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 user content")); + return messages; + } + + private static Map contentValidationSchema() { + Map schema = new LinkedHashMap<>(); + schema.put("type", "object"); + Map properties = new LinkedHashMap<>(); + properties.put("semantic_verdict", Map.of("type", "boolean")); + properties.put("errors", Map.of("type", "array")); + properties.put("params", Map.of("type", "object")); + schema.put("properties", properties); + schema.put("required", List.of("semantic_verdict", "errors", "params")); + schema.put("additionalProperties", false); + return schema; + } + + private static final class RecordingClient implements LLMClient { + List> lastMessages; + Map lastSchema; + String responseContent; + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + this.lastMessages = messages; + this.lastSchema = jsonSchema; + return new LLMResponse(responseContent, "test-model", Map.of(), Map.of()); + } + } + + @Test + void should_identifyStage_AndAppendAppendix_WhenSlotExtractionMatches() { + RecordingClient real = new RecordingClient(); + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + LLMClient wrapped = capture.wrap(real); + String system = loadResource(SLOT_EXTRACTION_SYSTEM); + List> messages = messagesWithSystem(system); + Map schema = Map.of("type", "object"); + + wrapped.structured(messages, schema, null, null); + + assertTrue(real.lastMessages.get(0).get("content").contains("[reasoning_appendix]")); + assertSame(schema, real.lastSchema); + // original list unmutated + assertTrue(messages.get(0).get("content").equals(system)); + } + + @Test + void should_extendSchema_WhenContentValidationMatches() { + RecordingClient real = new RecordingClient(); + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + LLMClient wrapped = capture.wrap(real); + String system = loadResource(CONTENT_VALIDATION_SYSTEM); + Map schema = contentValidationSchema(); + + wrapped.structured(messagesWithSystem(system), schema, null, null); + + String firstContent = real.lastMessages.get(0).get("content"); + assertTrue(firstContent.contains("[reasoning_appendix]")); + Map received = real.lastSchema; + assertTrue(received != schema, "schema should be a shallow copy, not the same instance"); + assertEquals("object", received.get("type")); + assertEquals(List.of("semantic_verdict", "errors", "params"), received.get("required")); + assertEquals(Boolean.FALSE, received.get("additionalProperties")); + @SuppressWarnings("unchecked") + Map props = (Map) received.get("properties"); + assertNotNull(props); + assertEquals(Map.of("type", "string"), props.get("reasoning")); + // original schema untouched + @SuppressWarnings("unchecked") + Map originalProps = (Map) schema.get("properties"); + assertFalse(originalProps.containsKey("reasoning")); + assertEquals(Map.of("type", "boolean"), originalProps.get("semantic_verdict")); + } + + @Test + void should_notModifySchema_WhenSlotExtractionMatches() { + RecordingClient real = new RecordingClient(); + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + LLMClient wrapped = capture.wrap(real); + String system = loadResource(SLOT_EXTRACTION_SYSTEM); + Map schema = Map.of("type", "object", "required", List.of("slots")); + + wrapped.structured(messagesWithSystem(system), schema, null, null); + + assertSame(schema, real.lastSchema); + } + + @Test + void should_passThrough_WhenNoStageMatches() { + RecordingClient real = new RecordingClient(); + real.responseContent = "{\"slots\":{}}"; + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + LLMClient wrapped = capture.wrap(real); + List> messages = messagesWithSystem("unrelated system prompt"); + Map schema = contentValidationSchema(); + + wrapped.structured(messages, schema, null, null); + + assertSame(messages, real.lastMessages); + assertSame(schema, real.lastSchema); + } + + @Test + void should_captureBothStages_ByLabel() { + RecordingClient real = new RecordingClient(); + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + LLMClient wrapped = capture.wrap(real); + String slotSystem = loadResource(SLOT_EXTRACTION_SYSTEM); + String contentSystem = loadResource(CONTENT_VALIDATION_SYSTEM); + + capture.beginScenario("x"); + real.responseContent = "{\"slots\":{},\"reasoning\":\"client-reason\"}"; + wrapped.structured(messagesWithSystem(slotSystem), Map.of(), null, null); + real.responseContent = + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{},\"reasoning\":\"server-reason\"}"; + wrapped.structured(messagesWithSystem(contentSystem), contentValidationSchema(), null, null); + capture.endScenario(); + + Optional captured = capture.capture("x"); + assertTrue(captured.isPresent()); + StageCapture s = captured.get(); + assertEquals("client-reason", s.clientReasoning()); + assertEquals("server-reason", s.serverReasoning()); + assertEquals("{\"slots\":{},\"reasoning\":\"client-reason\"}", s.clientRaw()); + assertEquals( + "{\"semantic_verdict\":true,\"errors\":[],\"params\":{},\"reasoning\":\"server-reason\"}", + s.serverRaw()); + } + + @Test + void should_returnNullReasoning_WhenFieldMissingOrNonString() { + RecordingClient real = new RecordingClient(); + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + LLMClient wrapped = capture.wrap(real); + String slotSystem = loadResource(SLOT_EXTRACTION_SYSTEM); + + capture.beginScenario("missing"); + real.responseContent = "{\"slots\":{}}"; + wrapped.structured(messagesWithSystem(slotSystem), Map.of(), null, null); + capture.endScenario(); + + StageCapture s = capture.capture("missing").orElseThrow(); + assertNull(s.clientReasoning()); + assertEquals("{\"slots\":{}}", s.clientRaw()); + + capture.beginScenario("nonstring"); + real.responseContent = "{\"slots\":{},\"reasoning\":123}"; + wrapped.structured(messagesWithSystem(slotSystem), Map.of(), null, null); + capture.endScenario(); + + StageCapture ns = capture.capture("nonstring").orElseThrow(); + assertNull(ns.clientReasoning()); + } + + @Test + void should_isolateCaptures_BetweenConcurrentThreads() throws Exception { + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + ThreadLocal perThreadResponse = new ThreadLocal<>(); + LLMClient fake = (messages, jsonSchema, temperature, maxTokens) -> + new LLMResponse(perThreadResponse.get(), "test-model", Map.of(), Map.of()); + LLMClient wrapped = capture.wrap(fake); + String slotSystem = loadResource(SLOT_EXTRACTION_SYSTEM); + + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + + Runnable taskA = () -> { + perThreadResponse.set("{\"slots\":{},\"reasoning\":\"A\"}"); + capture.beginScenario("label-a"); + ready.countDown(); + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + wrapped.structured(messagesWithSystem(slotSystem), Map.of(), null, null); + capture.endScenario(); + }; + Runnable taskB = () -> { + perThreadResponse.set("{\"slots\":{},\"reasoning\":\"B\"}"); + capture.beginScenario("label-b"); + ready.countDown(); + try { + release.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + wrapped.structured(messagesWithSystem(slotSystem), Map.of(), null, null); + capture.endScenario(); + }; + + Thread a = new Thread(taskA); + Thread b = new Thread(taskB); + a.start(); + b.start(); + ready.await(); + release.countDown(); + a.join(); + b.join(); + + StageCapture ca = capture.capture("label-a").orElseThrow(); + StageCapture cb = capture.capture("label-b").orElseThrow(); + assertEquals("A", ca.clientReasoning()); + assertEquals("B", cb.clientReasoning()); + assertEquals("{\"slots\":{},\"reasoning\":\"A\"}", ca.clientRaw()); + assertEquals("{\"slots\":{},\"reasoning\":\"B\"}", cb.clientRaw()); + } + + @Test + void should_resolveReasoningFlag_DefaultFalse() { + System.clearProperty("authz.reasoning"); + assertFalse(AuthzReasoningCapture.resolveReasoningFlag()); + } + + @Test + void should_resolveReasoningFlag_True() { + System.setProperty("authz.reasoning", "true"); + assertTrue(AuthzReasoningCapture.resolveReasoningFlag()); + System.setProperty("authz.reasoning", "TRUE"); + assertTrue(AuthzReasoningCapture.resolveReasoningFlag()); + System.setProperty("authz.reasoning", "false"); + assertFalse(AuthzReasoningCapture.resolveReasoningFlag()); + } + + @Test + void should_resolveReasoningFlag_ThrowOnInvalid() { + for (String value : List.of("yes", "1", "on")) { + System.setProperty("authz.reasoning", value); + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, AuthzReasoningCapture::resolveReasoningFlag); + assertTrue(ex.getMessage().contains("authz.reasoning")); + } + } +} 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 new file mode 100644 index 00000000..fe65211e --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainConcurrencyTest.java @@ -0,0 +1,225 @@ +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AuthzSampleMainConcurrencyTest { + + private static final String SLOT_EXTRACTION_SYSTEM = "/prompt_resources/prompts/slot_extraction/zh-CN/system.md"; + private static final String CONTENT_VALIDATION_SYSTEM = + "/prompt_resources/prompts/content_validation/zh-CN/system.md"; + + private static final String TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; + + private static final String ENV_CONTENT = + "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-test\n" + + "A2AT_LANGUAGE=zh-CN\nA2AT_PROMPT_SOURCE_TYPE=classpath\n" + + "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=\nA2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory\n"; + + private static String loadResource(String path) { + try (InputStream in = AuthzSampleMainConcurrencyTest.class.getResourceAsStream(path)) { + if (in == null) { + throw new IllegalStateException("Resource not found: " + path); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("Failed to read resource: " + path, e); + } + } + + private static List> messagesWithSystem(String systemContent) { + List> messages = new ArrayList<>(); + messages.add(Map.of("role", "system", "content", systemContent)); + messages.add(Map.of("role", "user", "content", "dummy")); + return messages; + } + + private static AuthzScenario successScenario(String label) { + return new AuthzScenario( + label, + "from_text", + Map.of("text", "hello"), + new AuthzExpected( + new ClientExpected(null, "prompt text", null), new ServerExpected("success", null, null))); + } + + private static ScenarioOutcome successOutcome() { + MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); + return new ScenarioOutcome( + new ScenarioResult("success", true, null, List.of(), true, true, null, List.of()), + metadata, + new FilledParamData(Map.of())); + } + + @Test + void should_buildGenerator_withoutCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, ENV_CONTENT); + + AuthzPromptGenerator generator = AuthzSampleMain.buildGenerator(envFile, TEMPLATE_URI, null); + assertNotNull(generator); + } + + @Test + void should_buildValidator_withoutCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, ENV_CONTENT); + + AuthzPromptValidator validator = AuthzSampleMain.buildValidator(envFile, null); + assertNotNull(validator); + } + + @Test + void should_buildGenerator_withCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, ENV_CONTENT); + + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + AuthzPromptGenerator generator = AuthzSampleMain.buildGenerator(envFile, TEMPLATE_URI, capture); + assertNotNull(generator); + } + + @Test + void should_buildValidator_withCapture_returnsNonNull(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, ENV_CONTENT); + + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + AuthzPromptValidator validator = AuthzSampleMain.buildValidator(envFile, capture); + assertNotNull(validator); + } + + @Test + void should_writeReport_includeReasoningFields_whenReasoningEnabled(@TempDir Path tempDir) throws IOException { + AuthzScenario s = successScenario("test"); + ScenarioOutcome outcome = successOutcome(); + + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + String[] content = {"unused"}; + LLMClient fake = (messages, jsonSchema, temperature, maxTokens) -> + new LLMResponse(content[0], "test-model", Map.of(), Map.of()); + LLMClient wrapped = capture.wrap(fake); + capture.beginScenario("test"); + content[0] = "{\"slots\":{},\"reasoning\":\"client-reason\"}"; + wrapped.structured(messagesWithSystem(loadResource(SLOT_EXTRACTION_SYSTEM)), Map.of(), null, null); + content[0] = "{\"semantic_verdict\":true,\"errors\":[],\"params\":{},\"reasoning\":\"server-reason\"}"; + wrapped.structured(messagesWithSystem(loadResource(CONTENT_VALIDATION_SYSTEM)), Map.of(), null, null); + capture.endScenario(); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 42.5, "sample/scenarios.json", true, capture); + + String reportContent = Files.readString(reportPath); + assertTrue(reportContent.contains("\"meta\"")); + assertTrue(reportContent.contains("\"workers\"")); + assertTrue(reportContent.contains("\"wallTimeSeconds\"")); + assertTrue(reportContent.contains("\"scenariosResource\"")); + assertTrue(reportContent.contains("\"reasoning\"")); + assertTrue(reportContent.contains("\"client_reasoning\"")); + assertTrue(reportContent.contains("\"server_reasoning\"")); + assertTrue(reportContent.contains("client-reason")); + assertTrue(reportContent.contains("server-reason")); + } + + @Test + void should_writeReport_omitReasoningFields_whenReasoningDisabled(@TempDir Path tempDir) throws IOException { + AuthzScenario s = successScenario("test"); + ScenarioOutcome outcome = successOutcome(); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); + + String reportContent = Files.readString(reportPath); + assertTrue(reportContent.contains("\"meta\"")); + assertTrue(reportContent.contains("\"reasoning\"")); + assertFalse(reportContent.contains("client_reasoning")); + assertFalse(reportContent.contains("server_reasoning")); + } + + @Test + void should_writeReport_includeClientReasoningOnly_whenServerMissing(@TempDir Path tempDir) throws IOException { + AuthzScenario s = new AuthzScenario( + "a-only", + "from_text", + Map.of("text", "hello"), + new AuthzExpected(new ClientExpected("slot.rule_violation", null, null), null)); + ScenarioOutcome outcome = new ScenarioOutcome( + new ScenarioResult("slot.rule_violation", false, null, List.of(), null, null, null, List.of()), + null, + null); + + AuthzReasoningCapture capture = new AuthzReasoningCapture(); + String[] content = {"unused"}; + LLMClient fake = (messages, jsonSchema, temperature, maxTokens) -> + new LLMResponse(content[0], "test-model", Map.of(), Map.of()); + LLMClient wrapped = capture.wrap(fake); + capture.beginScenario("a-only"); + content[0] = "{\"slots\":{},\"reasoning\":\"only-client\"}"; + wrapped.structured(messagesWithSystem(loadResource(SLOT_EXTRACTION_SYSTEM)), Map.of(), null, null); + capture.endScenario(); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 5.0, "sample/scenarios.json", true, capture); + + String reportContent = Files.readString(reportPath); + assertTrue(reportContent.contains("\"client_reasoning\"")); + assertTrue(reportContent.contains("only-client")); + assertFalse(reportContent.contains("server_reasoning")); + } + + @Test + void should_writeReport_useTimestampedFilename(@TempDir Path tempDir) throws IOException { + AuthzScenario s = successScenario("test"); + ScenarioOutcome outcome = successOutcome(); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); + + String fileName = reportPath.getFileName().toString(); + assertTrue(fileName.startsWith("authz-report-")); + assertTrue(fileName.endsWith(".json")); + String timestamp = fileName.substring("authz-report-".length(), fileName.length() - ".json".length()); + assertEquals(15, timestamp.length()); + } + + @Test + void should_writeReport_notOverwrite_WhenCalledTwice(@TempDir Path tempDir) + throws IOException, InterruptedException { + AuthzScenario s = successScenario("test"); + ScenarioOutcome outcome = successOutcome(); + + Path report1 = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); + Thread.sleep(1100); + Path report2 = AuthzSampleMain.writeReport( + List.of(s), List.of(outcome), tempDir, 8, 10.0, "sample/scenarios.json", false, null); + + assertNotEquals(report1, report2); + assertTrue(Files.exists(report1)); + assertTrue(Files.exists(report2)); + } +} 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..ce242775 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzSampleMainTest.java @@ -0,0 +1,237 @@ +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioResult; +import net.openan.a2at.sdk.core.model.MetadataContent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AuthzSampleMainTest { + + private static final AuthzExpected SUCCESS = new AuthzExpected( + new ClientExpected(null, "## rendered prompt", null), new ServerExpected("success", null, null)); + private static final ScenarioResult MATCH_RESULT = + new ScenarioResult("success", true, null, List.of(), true, true, true, List.of()); + private static final ScenarioResult MISMATCH_RESULT = + new ScenarioResult("slot.rule_violation", false, null, List.of(), null, null, null, List.of()); + + private PrintStream originalOut; + private ByteArrayOutputStream outContent; + + @BeforeEach + void setUp() { + originalOut = System.out; + outContent = new ByteArrayOutputStream(); + System.setOut(new PrintStream(outContent)); + } + + @AfterEach + void tearDown() { + System.setOut(originalOut); + } + + @Test + void should_returnZeroExitCode_WhenAllScenariosMatch() { + assertEquals(0, AuthzSampleMain.exitCode(List.of(MATCH_RESULT, MATCH_RESULT, MATCH_RESULT))); + } + + @Test + void should_returnNonZeroExitCode_WhenAnyMismatch() { + assertTrue(AuthzSampleMain.exitCode(List.of(MATCH_RESULT, MISMATCH_RESULT, MATCH_RESULT)) != 0); + } + + @Test + void should_resolveEnvPath_ArgsFirst() { + Path result = AuthzSampleMain.resolveEnvPath(new String[] {"custom.env"}); + assertEquals(Path.of("custom.env"), result); + } + + @Test + void should_resolveEnvPath_WorkingDirFallback() throws IOException { + Path cwdEnv = Path.of("authz.env"); + try { + Files.writeString(cwdEnv, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-test\n"); + Path result = AuthzSampleMain.resolveEnvPath(new String[0]); + assertEquals(cwdEnv.toAbsolutePath(), result.toAbsolutePath()); + } finally { + Files.deleteIfExists(cwdEnv); + } + } + + @Test + void should_resolveEnvPath_ClasspathFallback() { + Path result = AuthzSampleMain.resolveEnvPath(new String[0]); + assertTrue( + result.toString().contains("authz-policy") && result.toString().endsWith("authz.env")); + } + + @Test + void should_hasRequiredLlmKeys_ReturnTrue_WhenAllKeysPresent(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=sk-12345\n"); + assertTrue(AuthzSampleMain.hasRequiredLlmKeys(envFile)); + } + + @Test + void should_hasRequiredLlmKeys_ReturnFalse_WhenKeyMissing(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\n"); + assertFalse(AuthzSampleMain.hasRequiredLlmKeys(envFile)); + } + + @Test + void should_hasRequiredLlmKeys_ReturnFalse_WhenKeyBlank(@TempDir Path tempDir) throws IOException { + Path envFile = tempDir.resolve("test.env"); + Files.writeString(envFile, "A2AT_LLM_PROVIDER=openai\nA2AT_LLM_MODEL=gpt-4\nA2AT_LLM_API_KEY=\n"); + assertFalse(AuthzSampleMain.hasRequiredLlmKeys(envFile)); + } + + @Test + void should_hasRequiredLlmKeys_ReturnFalse_WhenFileNotFound() { + assertFalse(AuthzSampleMain.hasRequiredLlmKeys(Path.of("nonexistent.env"))); + } + + @Test + void should_loadParamSchema_ReturnBusinessLevelSchema() { + Map schema = AuthzSampleMain.loadParamSchema(); + + assertNotNull(schema); + assertTrue(schema.containsKey("properties")); + Map properties = (Map) schema.get("properties"); + assertTrue(properties.containsKey("operationType")); + assertTrue(properties.containsKey("policyList")); + } + + @Test + void should_printScenarioReport_WhenMetadataNull() { + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + ScenarioOutcome outcome = new ScenarioOutcome(MISMATCH_RESULT, null, null); + AuthzSampleMain.printScenarioReport(scenario, outcome); + String output = outContent.toString(); + assertTrue(output.contains("--- Scenario: test ---")); + assertTrue(output.contains("[Client]")); + assertTrue(output.contains("slot.rule_violation")); + assertTrue(output.contains("(跳过 - 客户端生成失败)")); + } + + @Test + void should_printScenarioReport_WhenFilledNull() { + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); + ScenarioResult result = new ScenarioResult( + "negotiation.semantic_rejected", false, null, List.of(), true, false, null, List.of()); + ScenarioOutcome outcome = new ScenarioOutcome(result, metadata, null); + AuthzSampleMain.printScenarioReport(scenario, outcome); + String output = outContent.toString(); + assertTrue(output.contains("[Server]")); + assertTrue(output.contains("negotiation.semantic_rejected")); + assertTrue(output.contains("校验结果")); + } + + @Test + void should_printSummary() { + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + AuthzSampleMain.printSummary(List.of(scenario), List.of(MATCH_RESULT, MISMATCH_RESULT, MISMATCH_RESULT)); + String output = outContent.toString(); + assertTrue(output.contains("Match")); + assertTrue(output.contains("Mismatch")); + } + + @Test + void should_writeReport_WithStagedExpectationsAndAssertions(@TempDir Path tempDir) throws IOException { + AuthzScenario scenario = new AuthzScenario( + "test", + "from_text", + Map.of("text", "hello"), + new AuthzExpected( + new ClientExpected(null, "prompt text", null), + new ServerExpected("success", null, Map.of("slot1", "expected_value")))); + MetadataContent metadata = new MetadataContent("template-uri", "prompt text", "extension-uri"); + ScenarioOutcome outcome = new ScenarioOutcome( + MATCH_RESULT, + metadata, + new net.openan.a2at.sdk.core.model.FilledParamData(Map.of("slot1", "actual_value"))); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(scenario), + List.of(outcome), + tempDir, + 8, + 12.5, + "sample/authz-policy/scenarios.json", + false, + null); + + assertTrue(Files.exists(reportPath)); + String content = Files.readString(reportPath); + assertTrue(content.contains("\"expected_client\"")); + assertTrue(content.contains("\"expected_server\"")); + assertTrue(content.contains("\"actual_outcome\"")); + assertTrue(content.contains("\"success\"")); + assertTrue(content.contains("\"match\"")); + assertTrue(content.contains("\"expected_value\"")); + assertTrue(content.contains("\"actual_params\"")); + assertTrue(content.contains("\"actual_value\"")); + assertTrue(content.contains("\"prompt_text\"")); + assertTrue(content.contains("\"prompt text\"")); + assertTrue(content.contains("\"assertions\"")); + assertTrue(content.contains("\"actual_slot_errors\"")); + } + + @Test + void should_writeReport_WithSlotErrors(@TempDir Path tempDir) throws IOException { + AuthzScenario scenario = new AuthzScenario( + "test", + "from_text", + Map.of("text", "hello"), + new AuthzExpected( + new ClientExpected( + "slot.not_provided", + null, + List.of(new AuthzScenario.SlotErrorExpectation("授权策略的操作类型", "slot.not_provided"))), + null)); + ScenarioResult result = new ScenarioResult( + "slot.not_provided", + true, + null, + List.of(new net.openan.a2at.sdk.core.model.SlotValidationError( + "授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。")), + null, + null, + null, + List.of()); + ScenarioOutcome outcome = new ScenarioOutcome(result, null, null); + + Path reportPath = AuthzSampleMain.writeReport( + List.of(scenario), + List.of(outcome), + tempDir, + 8, + 12.5, + "sample/authz-policy/scenarios.json", + false, + null); + + String content = Files.readString(reportPath); + assertTrue(content.contains("授权策略的操作类型")); + assertTrue(content.contains("slot.not_provided")); + assertTrue(content.contains("输入中未提供「授权策略的操作类型」。")); + } +} 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 new file mode 100644 index 00000000..c859a8bd --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioExecutorTest.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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class AuthzScenarioExecutorTest { + + private static final String TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; + private static final Map PARAM_SCHEMA = Map.of("properties", Map.of(), "required", Map.of()); + private static final AuthzExpected SUCCESS = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, null)); + + @AfterEach + void clearProperty() { + System.clearProperty("authz.workers"); + } + + private static AuthzScenario scenario(String label) { + return new AuthzScenario(label, "from_text", Map.of("text", "hello"), SUCCESS); + } + + @Test + void should_preserveInputOrder_WhenSlowerScenarioFinishesLast() throws Exception { + CountDownLatch gate = new CountDownLatch(2); + AuthzPromptGenerator generator = scenario -> { + if ("c0".equals(scenario.label())) { + try { + gate.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } else { + gate.countDown(); + } + return new MetadataContent(TEMPLATE_URI, scenario.label(), "Authorization-T/v1"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + List scenarios = List.of(scenario("c0"), scenario("c1"), scenario("c2")); + List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 3, null); + + assertEquals(3, outcomes.size()); + assertEquals("c0", outcomes.get(0).metadata().promptText()); + assertEquals("c1", outcomes.get(1).metadata().promptText()); + assertEquals("c2", outcomes.get(2).metadata().promptText()); + } + + @Test + void should_isolateFailures_WhenTaskThrows() { + AuthzPromptGenerator generator = scenario -> { + switch (scenario.label()) { + case "boom-runtime": + throw new RuntimeException("runtime failure"); + case "boom-assert": + throw new AssertionError("assertion failure"); + default: + return new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + } + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + List scenarios = + List.of(scenario("ok1"), scenario("boom-runtime"), scenario("ok2"), scenario("boom-assert")); + List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 4, null); + + assertEquals(4, outcomes.size()); + assertTrue(outcomes.get(0).result().match()); + assertEquals("infra.internal_error", outcomes.get(1).result().outcome()); + assertFalse(outcomes.get(1).result().match()); + assertTrue(outcomes.get(2).result().match()); + assertEquals("infra.internal_error", outcomes.get(3).result().outcome()); + assertFalse(outcomes.get(3).result().match()); + assertNotNull(outcomes.get(3).result().slotErrors()); + assertEquals(1, outcomes.get(3).result().slotErrors().size()); + assertEquals("_llm", outcomes.get(3).result().slotErrors().get(0).slotName()); + } + + @Test + void should_deliverProgressCallbacks_WithDistinctCompletionIndices() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + ConcurrentHashMap seen = new ConcurrentHashMap<>(); + List scenarios = List.of(scenario("a"), scenario("b"), scenario("c"), scenario("d")); + + List outcomes = + executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 4, (index, outcome, elapsed) -> { + assertTrue(elapsed >= 0.0); + seen.put(index, elapsed); + }); + + assertEquals(4, outcomes.size()); + assertEquals(Set.of(1, 2, 3, 4), seen.keySet()); + } + + @Test + void should_runAllScenariosSerially_WhenSingleWorker() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + List scenarios = List.of(scenario("a"), scenario("b"), scenario("c")); + List outcomes = executor.executeAll(scenarios, PARAM_SCHEMA, TEMPLATE_URI, 1, null); + + assertEquals(3, outcomes.size()); + for (ScenarioOutcome outcome : outcomes) { + assertTrue(outcome.result().match()); + } + } + + @Test + void should_returnEmptyList_WhenNoScenarios() { + AuthzScenarioRunner runner = new AuthzScenarioRunner( + s -> new MetadataContent("uri", "prompt", "ext"), (p, s, t) -> new FilledParamData(Map.of())); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + AtomicInteger callbackCalls = new AtomicInteger(); + List outcomes = executor.executeAll( + List.of(), PARAM_SCHEMA, TEMPLATE_URI, 8, (index, outcome, elapsed) -> callbackCalls.incrementAndGet()); + + assertTrue(outcomes.isEmpty()); + assertEquals(0, callbackCalls.get()); + } + + @Test + void should_notThrow_WhenProgressListenerIsNull() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenarioExecutor executor = new AuthzScenarioExecutor(runner); + + List outcomes = + executor.executeAll(List.of(scenario("a")), PARAM_SCHEMA, TEMPLATE_URI, 1, null); + + assertEquals(1, outcomes.size()); + assertTrue(outcomes.get(0).result().match()); + } + + @Test + void should_resolveWorkers_DefaultTo8() { + System.clearProperty("authz.workers"); + assertEquals(8, AuthzScenarioExecutor.resolveWorkers()); + } + + @Test + void should_resolveWorkers_FromSystemProperty() { + System.setProperty("authz.workers", "3"); + assertEquals(3, AuthzScenarioExecutor.resolveWorkers()); + } + + @Test + void should_resolveWorkers_ThrowOnNonPositive() { + for (String value : List.of("0", "-1")) { + System.setProperty("authz.workers", value); + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, AuthzScenarioExecutor::resolveWorkers); + assertTrue(ex.getMessage().contains("authz.workers")); + } + } + + @Test + void should_resolveWorkers_ThrowOnNonNumeric() { + System.setProperty("authz.workers", "abc"); + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, AuthzScenarioExecutor::resolveWorkers); + assertTrue(ex.getMessage().contains("authz.workers")); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java new file mode 100644 index 00000000..d46f8c3c --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioLoaderTest.java @@ -0,0 +1,169 @@ +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AuthzScenarioLoaderTest { + + @Test + void should_loadAndValidateAllScenarios() { + List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios.json"); + + assertEquals(15, scenarios.size()); + // 预期成功在前(index 0-7),预期拒绝在中(index 8-12),客户端拦截在末尾(index 13-14) + assertEquals("c1-nl-add-01", scenarios.get(0).label()); + assertEquals("from_text", scenarios.get(0).entry()); + assertEquals("success", scenarios.get(0).expected().server().outcome()); + assertEquals("c1-nl-add-01-varname", scenarios.get(1).label()); + assertEquals("from_text", scenarios.get(1).entry()); + assertEquals("success", scenarios.get(1).expected().server().outcome()); + assertNotNull(scenarios.get(1).validateSchema()); + assertEquals("c3-nl-mod-06", scenarios.get(3).label()); + assertEquals("success", scenarios.get(3).expected().server().outcome()); + assertEquals("c3-nl-mod-06-varfields", scenarios.get(4).label()); + assertEquals("success", scenarios.get(4).expected().server().outcome()); + assertNotNull(scenarios.get(4).validateSchema()); + assertEquals("b4-nl-bad-id-01", scenarios.get(7).label()); + assertEquals("success", scenarios.get(7).expected().server().outcome()); + assertEquals("b3-nl-invalid-mod-01", scenarios.get(8).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(8).expected().server().outcome()); + assertEquals("b2-nl-format-01", scenarios.get(9).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(9).expected().server().outcome()); + assertEquals("b2-nl-format-01-varreq", scenarios.get(10).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(10).expected().server().outcome()); + assertNotNull(scenarios.get(10).validateSchema()); + assertEquals("b1-nl-missing-01", scenarios.get(11).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(11).expected().server().outcome()); + assertEquals("c6-nl-mixed-07", scenarios.get(12).label()); + assertEquals( + "negotiation.semantic_rejected", + scenarios.get(12).expected().server().outcome()); + assertEquals("a-data-starve-01", scenarios.get(13).label()); + assertEquals("from_data_with_schema", scenarios.get(13).entry()); + assertEquals("slot.not_provided", scenarios.get(13).expected().client().outcome()); + assertNull(scenarios.get(13).expected().server()); + assertEquals("a-nl-neg-01", scenarios.get(14).label()); + assertEquals("slot.not_provided", scenarios.get(14).expected().client().outcome()); + assertNull(scenarios.get(14).expected().server()); + } + + @Test + void should_loadAndValidateFullSuiteScenarios() { + List scenarios = AuthzScenarioLoader.load("sample/authz-policy/scenarios-100.json"); + + assertEquals(102, scenarios.size()); + long variants = scenarios.stream() + .filter(s -> s.label().endsWith("-varname") + || s.label().endsWith("-varfields") + || s.label().endsWith("-varflat") + || s.label().endsWith("-varreq") + || s.label().endsWith("-varsch") + || s.label().endsWith("-varuuid") + || s.label().endsWith("-vardesc") + || s.label().endsWith("-dual")) + .count(); + assertEquals(47, variants); + long clientInterceptions = scenarios.stream() + .filter(s -> + s.expected().client() != null && s.expected().client().outcome() != null) + .count(); + assertEquals(9, clientInterceptions); + long serverRejections = scenarios.stream() + .filter(s -> s.expected().server() != null + && "negotiation.semantic_rejected" + .equals(s.expected().server().outcome())) + .count(); + assertEquals(37, serverRejections); + long validateSchemas = + scenarios.stream().filter(s -> s.validateSchema() != null).count(); + assertEquals(43, validateSchemas); + } + + @Test + void should_throwIllegalStateException_WhenResourceNotFound() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, () -> AuthzScenarioLoader.load("sample/authz-policy/nonexistent.json")); + + assertTrue(ex.getMessage().contains("not found")); + } + + @Test + void should_throwIllegalStateException_WhenJsonMalformed() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, () -> AuthzScenarioLoader.load("sample/authz-policy/test/malformed.json")); + + assertTrue(ex.getMessage().contains("malformed") || ex.getMessage().contains("parse")); + } + + @Test + void should_throwIllegalArgumentException_WhenScenarioHasInvalidEntry() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/invalid-scenarios.json")); + + assertTrue(ex.getMessage().contains("bad-scenario")); + } + + @Test + void should_throwIllegalStateException_WhenScenariosIsNotAnArray() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/scenarios-as-string.json")); + + assertTrue(ex.getMessage().contains("scenarios")); + } + + @Test + void should_throwIllegalStateException_WhenScenarioLabelIsNotAString() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/scenario-label-non-string.json")); + + assertTrue(ex.getMessage().contains("label")); + } + + @Test + void should_parseValidateSchema_WhenPresent() { + List scenarios = + AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-scenarios.json"); + + assertEquals(2, scenarios.size()); + AuthzScenario present = scenarios.get(0); + assertEquals("vs-present", present.label()); + assertNotNull(present.validateSchema()); + assertEquals("应为简短的处置动作短语", ((Map) present.validateSchema().get("动网操作的授权策略列表")).get("处置类型")); + } + + @Test + void should_haveNullValidateSchema_WhenAbsent() { + List scenarios = + AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-scenarios.json"); + + assertEquals("vs-missing", scenarios.get(1).label()); + assertNull(scenarios.get(1).validateSchema()); + } + + @Test + void should_throwIllegalStateException_WhenValidateSchemaIsEmpty() { + IllegalStateException ex = assertThrows( + IllegalStateException.class, + () -> AuthzScenarioLoader.load("sample/authz-policy/test/validate-schema-empty.json")); + + assertTrue(ex.getMessage().contains("validate_schema")); + } +} 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..4a3caf2a --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioRunnerTest.java @@ -0,0 +1,577 @@ +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.SlotErrorExpectation; +import net.openan.a2at.sample.authz_policy.AuthzScenarioRunner.ScenarioOutcome; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.SlotValidationError; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import org.junit.jupiter.api.Test; + +class AuthzScenarioRunnerTest { + + private static final String TEMPLATE_URI = StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI; + private static final Map PARAM_SCHEMA = Map.of("properties", Map.of(), "required", Map.of()); + private static final AuthzExpected SUCCESS = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), new ServerExpected("success", null, null)); + private static final AuthzExpected EXPECTED_SLOT_ERROR = + new AuthzExpected(new ClientExpected("slot.rule_violation", null, null), null); + private static final AuthzExpected EXPECTED_SEMANTIC_REJECTED = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), + new ServerExpected("negotiation.semantic_rejected", null, null)); + + @Test + void should_dispatchToFromTextGenerator() { + AtomicReference calledEntry = new AtomicReference<>(); + AuthzPromptGenerator generator = scenario -> { + calledEntry.set(scenario.entry()); + return new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertEquals("from_text", calledEntry.get()); + } + + @Test + void should_dispatchToFromDataWithSchemaGenerator() { + AtomicReference calledEntry = new AtomicReference<>(); + AuthzPromptGenerator generator = scenario -> { + calledEntry.set(scenario.entry()); + return new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario( + "test", "from_data_with_schema", Map.of("data", Map.of("k", "v"), "schema", Map.of("k", "d")), SUCCESS); + + runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertEquals("from_data_with_schema", calledEntry.get()); + } + + @Test + void should_match_WhenExpectedSuccessAndValidationPasses() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals("success", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNotNull(outcome.filled()); + } + + @Test + void should_match_WhenExpectedSemanticRejectedAndSemanticRejected() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), "semantic rejected"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = + new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SEMANTIC_REJECTED); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals("negotiation.semantic_rejected", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidationRejected() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException( + ErrorCatalog.NEGOTIATION_SEMANTIC_REJECTED.getCode(), "semantic rejected"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("negotiation.semantic_rejected", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSemanticRejectedAndValidationPasses() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("k", "v")); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = + new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SEMANTIC_REJECTED); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("success", outcome.result().outcome()); + assertNotNull(outcome.metadata()); + assertNotNull(outcome.filled()); + } + + @Test + void should_match_WhenPromptTextDiffersFromExpectation() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "different prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals(Boolean.FALSE, outcome.result().clientPromptMatch()); + assertEquals(Boolean.TRUE, outcome.result().serverOutcomeMatch()); + assertTrue(outcome.result().serverParamsMatch()); + } + + @Test + void should_match_WhenPromptTextDiffersAndServerMatches() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "expected prompt", null), + new ServerExpected("success", null, Map.of("操作类型", "新增授权策略"))); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "drifted prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of("操作类型", "新增授权策略")); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals(Boolean.FALSE, outcome.result().clientPromptMatch()); + assertEquals(Boolean.TRUE, outcome.result().serverParamsMatch()); + } + + @Test + void should_match_WhenPromptTextDiffersOnlyInTrailingWhitespace() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "generated prompt \n", null), new ServerExpected("success", null, null)); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, " generated prompt\n", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndGeneratorThrowsPromptGenerationException() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException("slot.schema_not_found", "schema not found"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("slot.schema_not_found", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_match_WhenExpectedSlotValidationErrorAndGeneratorThrowsSlotValidationError() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + ErrorCatalog.SLOT_RULE_VIOLATION.getCode(), "Required slots are missing or empty: operation_type"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SLOT_ERROR); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals("slot.rule_violation", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSlotValidationErrorAndGeneratorThrowsInfrastructureError() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), "LLM invocation failed"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), EXPECTED_SLOT_ERROR); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("llm.invocation_failed", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidatorThrowsInfrastructureError() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException(ErrorCatalog.LLM_INVOCATION_FAILED.getCode(), "infrastructure error"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("llm.invocation_failed", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidatorThrowsResourceNotFound() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException(ErrorCatalog.TEMPLATE_NOT_FOUND.getCode(), "resource not found"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("template.not_found", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndValidatorThrowsUnknownCode() { + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + throw new ContentValidationException("unknown_code", "unknown error"); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("unknown_code", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNotNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_matchSlotErrors_WhenExpectedSlotErrorsMatchActual() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + "输入中未提供「授权策略的操作类型」。", + List.of(new SlotValidationError("授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。"))); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzExpected expected = new AuthzExpected( + new ClientExpected( + "slot.not_provided", null, List.of(new SlotErrorExpectation("授权策略的操作类型", "slot.not_provided"))), + null); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals(1, outcome.result().slotErrors().size()); + assertEquals("授权策略的操作类型", outcome.result().slotErrors().get(0).slotName()); + assertEquals("slot.not_provided", outcome.result().slotErrors().get(0).code()); + } + + @Test + void should_notMatchSlotErrors_WhenExpectedSlotErrorsDontMatchActual() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + "输入中未提供「授权策略的操作类型」。", + List.of(new SlotValidationError("授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。"))); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzExpected expected = new AuthzExpected( + new ClientExpected( + "slot.not_provided", + null, + List.of(new SlotErrorExpectation("动网操作的授权策略列表", "slot.not_provided"))), + null); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("slot.not_provided", outcome.result().outcome()); + } + + @Test + void should_notMatchSlotErrors_WhenSlotNameMatchesButCodeDiffers() { + AuthzPromptGenerator generator = scenario -> { + throw new PromptGenerationException( + ErrorCatalog.SLOT_NOT_PROVIDED.getCode(), + "输入中未提供「授权策略的操作类型」。", + List.of(new SlotValidationError("授权策略的操作类型", "slot.not_provided", "输入中未提供「授权策略的操作类型」。"))); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzExpected expected = new AuthzExpected( + new ClientExpected( + "slot.not_provided", + null, + List.of(new SlotErrorExpectation("授权策略的操作类型", "slot.constraint_violated"))), + null); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + } + + @Test + void should_matchParams_WhenActualIsSupersetOfExpected() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), + new ServerExpected("success", null, Map.of("操作类型", "新增授权策略", "策略列表", List.of(Map.of("业务场景", "校园专网"))))); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + Map actualEntry = new java.util.LinkedHashMap<>(); + actualEntry.put("策略标识", null); + actualEntry.put("业务场景", "校园专网"); + actualEntry.put("处置类型", "紧急扩容"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> + new FilledParamData(Map.of("操作类型", "新增授权策略", "策略列表", List.of(actualEntry))); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertEquals(Boolean.TRUE, outcome.result().serverParamsMatch()); + } + + @Test + void should_notMatchParams_WhenListItemValueDiffers() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), + new ServerExpected("success", null, Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"))))); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = + (prompt, schema, templateUri) -> new FilledParamData(Map.of("策略列表", List.of(Map.of("业务场景", "医疗专线")))); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals(Boolean.FALSE, outcome.result().serverParamsMatch()); + } + + @Test + void should_notMatchParams_WhenListLengthDiffers() { + AuthzExpected expected = new AuthzExpected( + new ClientExpected(null, "generated prompt", null), + new ServerExpected("success", null, Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"))))); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> + new FilledParamData(Map.of("策略列表", List.of(Map.of("业务场景", "校园专网"), Map.of("业务场景", "医疗专线")))); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), expected); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals(Boolean.FALSE, outcome.result().serverParamsMatch()); + } + + @Test + void should_notMatch_WhenExpectedSuccessAndGeneratorThrowsRuntimeException() { + AuthzPromptGenerator generator = scenario -> { + throw new NullPointerException("unexpected NPE"); + }; + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertFalse(outcome.result().match()); + assertEquals("infra.internal_error", outcome.result().outcome()); + assertNotNull(outcome.result().error()); + assertNull(outcome.metadata()); + assertNull(outcome.filled()); + } + + @Test + void should_warn_WhenMutationAndEmptyPolicyListSection() { + String promptText = "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); + } + + @Test + void should_warn_WhenModifyAndEmptyPolicyListSection() { + String promptText = "## 授权策略的操作类型\n修改授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); + } + + @Test + void should_notWarn_WhenQueryAndEmptyPolicyListSection() { + String promptText = "## 授权策略的操作类型\n查询授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().isEmpty()); + } + + @Test + void should_notWarn_WhenMutationAndNonEmptyPolicyListSection() { + String promptText = "## 授权策略的操作类型\n新增授权策略\n\n## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n校园专网,紧急扩容\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().isEmpty()); + } + + @Test + void should_notWarn_WhenPromptTextHasUnknownSectionOrder() { + String promptText = "## 授权策略的操作描述\n描述\n\n## 授权策略的操作类型\n新增授权策略\n\n## 预期输出\n输出格式\n\n## 动网操作的授权策略列表\n\n"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().contains("empty_policy_list_section")); + } + + @Test + void should_notWarn_WhenNoOperationTypeSection() { + String promptText = "## 授权策略的操作描述\n描述\n\n## 动网操作的授权策略列表\n\n## 预期输出\n输出格式"; + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, promptText, "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> new FilledParamData(Map.of()); + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + ScenarioOutcome outcome = runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertTrue(outcome.result().match()); + assertTrue(outcome.result().warnings().isEmpty()); + } + + @Test + void should_useScenarioSchema_WhenValidateSchemaPresent() { + Map scenarioSchema = Map.of("处置规则", "应为短语"); + AtomicReference> receivedSchema = new AtomicReference<>(); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + receivedSchema.set(schema); + return new FilledParamData(Map.of()); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = + new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS, scenarioSchema); + + runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertSame(scenarioSchema, receivedSchema.get()); + } + + @Test + void should_useDefaultSchema_WhenValidateSchemaAbsent() { + AtomicReference> receivedSchema = new AtomicReference<>(); + AuthzPromptGenerator generator = + scenario -> new MetadataContent(TEMPLATE_URI, "generated prompt", "Authorization-T/v1"); + AuthzPromptValidator validator = (prompt, schema, templateUri) -> { + receivedSchema.set(schema); + return new FilledParamData(Map.of()); + }; + AuthzScenarioRunner runner = new AuthzScenarioRunner(generator, validator); + AuthzScenario scenario = new AuthzScenario("test", "from_text", Map.of("text", "hello"), SUCCESS); + + runner.run(scenario, PARAM_SCHEMA, TEMPLATE_URI); + + assertSame(PARAM_SCHEMA, receivedSchema.get()); + } +} 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..257ed095 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/authz_policy/AuthzScenarioTest.java @@ -0,0 +1,238 @@ +package net.openan.a2at.sample.authz_policy; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import net.openan.a2at.sample.authz_policy.AuthzScenario.AuthzExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ClientExpected; +import net.openan.a2at.sample.authz_policy.AuthzScenario.ServerExpected; +import org.junit.jupiter.api.Test; + +class AuthzScenarioTest { + + private static AuthzExpected success() { + return new AuthzExpected( + new ClientExpected(null, "## rendered prompt", null), new ServerExpected("success", null, null)); + } + + @Test + void should_acceptValidFromTextScenario() { + AuthzScenario scenario = new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success()); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_acceptValidFromDataWithSchemaScenario() { + AuthzScenario scenario = new AuthzScenario( + "add-from-data", + "from_data_with_schema", + Map.of("data", Map.of("k", "v"), "schema", Map.of("k", "description")), + success()); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_rejectInvalidEntry() { + AuthzScenario scenario = new AuthzScenario("bad", "invalid_entry", Map.of("text", "test"), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("Invalid entry: invalid_entry", ex.getMessage()); + } + + @Test + void should_rejectInvalidClientOutcome() { + AuthzScenario scenario = new AuthzScenario( + "bad", + "from_text", + Map.of("text", "test"), + new AuthzExpected(new ClientExpected("invalid_outcome", null, null), null)); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("Invalid expected client outcome: invalid_outcome", ex.getMessage()); + } + + @Test + void should_rejectClientOutcomeWithServerExpectation() { + AuthzScenario scenario = new AuthzScenario( + "bad", + "from_text", + Map.of("text", "test"), + new AuthzExpected( + new ClientExpected("slot.rule_violation", null, null), + new ServerExpected("success", null, null))); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("expected.server must be null when expected.client.outcome is set", ex.getMessage()); + } + + @Test + void should_rejectClientSuccessWithoutPromptText() { + AuthzScenario scenario = new AuthzScenario( + "bad", + "from_text", + Map.of("text", "test"), + new AuthzExpected(new ClientExpected(null, " ", null), new ServerExpected("success", null, null))); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("expected.client.promptText must be non-blank when client outcome is not set", ex.getMessage()); + } + + @Test + void should_rejectClientSuccessWithoutServerExpectation() { + AuthzScenario scenario = new AuthzScenario( + "bad", + "from_text", + Map.of("text", "test"), + new AuthzExpected(new ClientExpected(null, "prompt", null), null)); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertTrue(ex.getMessage().startsWith("expected.server.outcome must be one of")); + assertTrue(ex.getMessage().contains("when client succeeds")); + } + + @Test + void should_acceptSlotValidationErrorOutcome() { + AuthzScenario scenario = new AuthzScenario( + "reject", + "from_text", + Map.of("text", "test"), + new AuthzExpected(new ClientExpected("slot.rule_violation", null, null), null)); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_acceptSemanticRejectedServerOutcome() { + AuthzScenario scenario = new AuthzScenario( + "reject", + "from_text", + Map.of("text", "test"), + new AuthzExpected( + new ClientExpected(null, "prompt", null), + new ServerExpected("negotiation.semantic_rejected", null, null))); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_rejectNullExpected() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of("text", "test"), null); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("expected.client must not be null", ex.getMessage()); + } + + @Test + void should_rejectFromTextMissingText() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of(), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_text scenario requires a non-blank input.text", ex.getMessage()); + } + + @Test + void should_rejectFromTextBlankText() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", Map.of("text", " "), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_text scenario requires a non-blank input.text", ex.getMessage()); + } + + @Test + void should_rejectFromDataMissingData() { + AuthzScenario scenario = + new AuthzScenario("bad", "from_data_with_schema", Map.of("schema", Map.of("k", "d")), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.data", ex.getMessage()); + } + + @Test + void should_rejectFromDataMissingSchema() { + AuthzScenario scenario = + new AuthzScenario("bad", "from_data_with_schema", Map.of("data", Map.of("k", "v")), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.schema", ex.getMessage()); + } + + @Test + void should_rejectFromDataWithSchema_EmptyData() { + AuthzScenario scenario = new AuthzScenario( + "bad", "from_data_with_schema", Map.of("data", Map.of(), "schema", Map.of("k", "d")), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.data", ex.getMessage()); + } + + @Test + void should_rejectFromDataWithSchema_EmptySchema() { + AuthzScenario scenario = new AuthzScenario( + "bad", "from_data_with_schema", Map.of("data", Map.of("k", "v"), "schema", Map.of()), success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("from_data_with_schema scenario requires a non-empty input.schema", ex.getMessage()); + } + + @Test + void should_rejectNullInput() { + AuthzScenario scenario = new AuthzScenario("bad", "from_text", null, success()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("input must not be null", ex.getMessage()); + } + + @Test + void should_rejectNullScenario() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(null)); + assertEquals("scenario must not be null", ex.getMessage()); + } + + @Test + void should_haveNullValidateSchema_WhenFourArgConstructor() { + AuthzScenario scenario = new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success()); + + assertNull(scenario.validateSchema()); + } + + @Test + void should_acceptNonEmptyValidateSchema() { + AuthzScenario scenario = new AuthzScenario( + "add-from-text", + "from_text", + Map.of("text", "test"), + success(), + Map.of("动网操作的授权策略列表", Map.of("处置类型", "应为简短的处置动作短语"))); + + assertDoesNotThrow(() -> AuthzScenario.validate(scenario)); + } + + @Test + void should_rejectEmptyValidateSchema() { + AuthzScenario scenario = + new AuthzScenario("add-from-text", "from_text", Map.of("text", "test"), success(), Map.of()); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> AuthzScenario.validate(scenario)); + assertEquals("validateSchema must be non-empty when set", ex.getMessage()); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/InformationNegotiationSchemasTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/InformationNegotiationSchemasTest.java new file mode 100644 index 00000000..fa6ac446 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/InformationNegotiationSchemasTest.java @@ -0,0 +1,102 @@ +package net.openan.a2at.sample.negotiation; + +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.negotiation.shared.InformationNegotiationSchemas; +import org.junit.jupiter.api.Test; + +class InformationNegotiationSchemasTest { + + @Test + void proposeSchemaDefinesRequestedItemsAndTheirRequirements() { + Map schema = InformationNegotiationSchemas.propose(); + Map properties = propertiesOf(schema); + + 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 acceptSchemaDefinesItemsAndSuppliedValues() { + Map schema = InformationNegotiationSchemas.accept(); + Map properties = propertiesOf(schema); + + assertBaseSchema(schema, List.of("items")); + assertEquals(Set.of("items"), properties.keySet()); + assertItemArray(properties.get("items"), "value"); + } + + @Test + void rejectSchemaDefinesItemsAndRejectionReasons() { + Map schema = InformationNegotiationSchemas.reject(); + Map properties = propertiesOf(schema); + + 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 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/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..cba5035b --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/negotiation/eval/NegotiationFromDataApiEvalAppTest.java @@ -0,0 +1,142 @@ +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 net.openan.a2at.sample.negotiation.shared.InformationNegotiationSchemas; +import org.junit.jupiter.api.Test; + +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 new file mode 100644 index 00000000..000bc86b --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationEvaluationCaseLoaderTest.java @@ -0,0 +1,158 @@ +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.List; +import java.util.Map; +import net.openan.a2at.sample.private_line_complaint.negotiation.evaluation.NegotiationEvaluationCase; +import net.openan.a2at.sample.private_line_complaint.negotiation.evaluation.NegotiationEvaluationCaseLoader; +import org.junit.jupiter.api.Test; + +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)); + } + + @Test + void loadsFocusedReproductionCasesFromTheCanonicalCorpus() { + var cases = NegotiationEvaluationCaseLoader.loadSelected(List.of("R21", "P01", "A28")); + + 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 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().filter(flow -> flow.decision().equals("reject")).allMatch(flow -> flow.expectedEnding() + .keySet() + .equals(Map.of("接入端口名称", "", "投诉分类", "").keySet()))); + } + + @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 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(); + + for (NegotiationEvaluationCase testCase : cases) { + String prompt = testCase.completedPrompt(); + assertTrue(!prompt.contains("协商上下文"), testCase.id()); + assertTrue(!prompt.contains("{{"), testCase.id()); + assertTrue( + prompt.contains(testCase.phase().equals("propose") ? "## 信息协商\n" : "## 信息协商结果\n"), testCase.id()); + } + } + + private static boolean isComplete(NegotiationEvaluationCase testCase) { + return !testCase.id().isBlank() + && !testCase.category().isBlank() + && !testCase.text().isBlank() + && testCase.completedPrompt() != null + && !testCase.completedPrompt().isBlank() + && testCase.expected() != null + && !testCase.expected().isEmpty() + && expectedKeys().equals(testCase.expected().keySet()); + } + + private static java.util.Set expectedKeys() { + return Map.of("接入端口名称", "", "投诉分类", "").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..fcb1bd21 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationMetadataReaderTest.java @@ -0,0 +1,121 @@ +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 net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +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); + + assertEquals( + "prompt", 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, + MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY, + Map.of( + "id", + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round", + 1, + "maxRounds", + 5, + "performative", + "PROPOSE")); + + assertEquals( + new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5, NegotiationPerformative.PROPOSE), + 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 readsThePerformativeOfTheNegotiationContext() { + Map metadata = Map.of( + MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY, + Map.of( + "id", + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round", + 1, + "maxRounds", + 5, + "performative", + "PROPOSE")); + + assertEquals( + new NegotiationContext("3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", 1, 5, NegotiationPerformative.PROPOSE), + NegotiationMetadataReader.readContext(metadata)); + } + + @Test + void rejectsAContextWithoutAPerformative() { + Map metadata = Map.of( + MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY, + Map.of("id", "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", "round", 1, "maxRounds", 5)); + + assertThrows(IllegalArgumentException.class, () -> NegotiationMetadataReader.readContext(metadata)); + } + + @Test + void rejectsAnUnknownPerformativeOfTheNegotiationContext() { + Map metadata = Map.of( + MetadataContent.NEGOTIATION_CONTEXT_METADATA_KEY, + Map.of( + "id", + "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3", + "round", + 1, + "maxRounds", + 5, + "performative", + "propose")); + + assertThrows(IllegalArgumentException.class, () -> NegotiationMetadataReader.readContext(metadata)); + } + + @Test + void rejectsMissingPromptAndWrongTemplateOrExtension() { + assertThrows( + IllegalArgumentException.class, + () -> NegotiationMetadataReader.readPrompt( + Map.of(MetadataContent.TEMPLATE_URI_METADATA_KEY, NegotiationSampleFlow.PROPOSE_TEMPLATE_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), + 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..97bd5677 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/NegotiationSampleFlowTest.java @@ -0,0 +1,108 @@ +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; +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, result.propose().templateUri()); + assertEquals(NegotiationSampleFlow.ENDING_TEMPLATE_URI, result.ending().templateUri()); + assertEquals( + ExtensionUriConstants.NEGOTIATION_T_EXTENSION_URI, + result.propose().extensionUri()); + assertContextIsShared(result); + assertEquals( + List.of( + Map.of( + "name", "接入端口名称", + "value", "P781-珠江新城-PTN7900-23-TPA1EG24-17(cvlan=100)"), + Map.of("name", "投诉分类", "value", "专线质差")), + result.endingData().data().get("items")); + } + + @Test + void rejectFlowCoversFourApis() throws IOException { + NegotiationSampleFlow.NegotiationFlowResult result = run(NegotiationDecision.REJECT); + + assertEquals(NegotiationDecision.REJECT, result.decision()); + assertContextIsShared(result); + assertEquals( + List.of( + Map.of("name", "接入端口名称", "reason", "当前账号没有资源系统查询权限"), + Map.of("name", "投诉分类", "reason", "当前账号没有资源系统查询权限")), + result.endingData().data().get("items")); + } + + 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( + 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")); + } +} 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..f966b5a0 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/private_line_complaint/negotiation/evaluation/NegotiationEvaluationProcessLoggerTest.java @@ -0,0 +1,39 @@ +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()); + } +} 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..54a81126 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/accuracy/NotificationTAccuracyTest.java @@ -0,0 +1,315 @@ +package net.openan.a2at.sample.service_recovery.accuracy; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("integration") +class NotificationTAccuracyTest { + + private static final ObjectMapper MAPPER = + JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build(); + + private static final String TEMPLATE_URI_STR = "Notification-T/network-layer/service-recovery/v1"; + private static final String DATA_SCHEMA_RESOURCE = "sample/service-recovery/client/schema.json"; + private static final String VALIDATION_SCHEMA_RESOURCE = "sample/service-recovery/server/schema.json"; + + private static A2ATClient client; + private static A2ATServer server; + private static String templateUri; + private static Map dataSchema; + private static Map validationSchema; + private static boolean apiKeyConfigured; + private static Map envInfo; + + private static final List> fromTextResults = new ArrayList<>(); + private static final List> fromDataResults = new ArrayList<>(); + private static final List> validateResults = new ArrayList<>(); + + @BeforeAll + static void setup() throws Exception { + templateUri = TEMPLATE_URI_STR; + + dataSchema = loadJsonResource(DATA_SCHEMA_RESOURCE); + validationSchema = loadJsonResource(VALIDATION_SCHEMA_RESOURCE); + + Path env = resolveEnvFile("service-recovery-accuracy-test/client.env"); + envInfo = readEnvInfo(env); + + String apiKey = envInfo.get("A2AT_LLM_API_KEY"); + apiKeyConfigured = apiKey != null && !apiKey.isBlank() && !apiKey.contains("${"); + + if (apiKeyConfigured) { + client = new A2ATClient(env); + server = new A2ATServer(env); + } + } + + @AfterAll + static void writeReport() throws Exception { + Map report = new LinkedHashMap<>(); + + Map testRun = new LinkedHashMap<>(); + testRun.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + testRun.put("env", envInfo); + testRun.put("apiKeyConfigured", apiKeyConfigured); + report.put("testRun", testRun); + + report.put("generateNotificationPromptFromText", Map.of("testCases", fromTextResults)); + report.put("generateNotificationPromptFromDataWithSchema", Map.of("testCases", fromDataResults)); + report.put("validateNotificationPromptAndDataFilling", Map.of("testCases", validateResults)); + + Path reportDir = Path.of("target"); + Files.createDirectories(reportDir); + Path reportFile = reportDir.resolve("service-recovery-accuracy-report.json"); + MAPPER.writeValue(reportFile.toFile(), report); + System.out.println("Report written to: " + reportFile.toAbsolutePath().normalize()); + } + + @Test + void generateNotificationPromptFromText() throws Exception { + if (!apiKeyConfigured) { + System.out.println("SKIP: generateNotificationPromptFromText - API key not configured"); + return; + } + List cases = loadTextCases("service-recovery-accuracy-test/test-cases-generate-from-text.json"); + System.out.println("== generateNotificationPromptFromText =="); + boolean first = true; + for (TextCase tc : cases) { + if (!first) System.out.println("---"); + first = false; + Map entry = new LinkedHashMap<>(); + entry.put("name", tc.name); + entry.put("input", Map.of("text", tc.text)); + + try { + MetadataContent result = client.generateNotificationPromptFromText(tc.text, templateUri); + entry.put("output", metadataOutput(result)); + System.out.println(" " + tc.name + ": " + compact(result.promptText())); + } catch (Exception e) { + entry.put("output", Map.of("error", e.getClass().getName() + ": " + e.getMessage())); + System.out.println(" " + tc.name + ": ERROR - " + e.getMessage()); + } + fromTextResults.add(entry); + } + } + + @Test + void generateNotificationPromptFromDataWithSchema() throws Exception { + if (!apiKeyConfigured) { + System.out.println("SKIP: generateNotificationPromptFromDataWithSchema - API key not configured"); + return; + } + List cases = loadDataCases("service-recovery-accuracy-test/test-cases-generate-from-data.json"); + System.out.println("== generateNotificationPromptFromDataWithSchema =="); + boolean first = true; + for (DataCase dc : cases) { + if (!first) System.out.println("---"); + first = false; + Map entry = new LinkedHashMap<>(); + entry.put("name", dc.name); + entry.put("input", Map.of("data", dc.data)); + + try { + MetadataContent result = + client.generateNotificationPromptFromDataWithSchema(dc.data, dataSchema, templateUri); + entry.put("output", metadataOutput(result)); + System.out.println(" " + dc.name + ": " + compact(result.promptText())); + } catch (Exception e) { + entry.put("output", Map.of("error", e.getClass().getName() + ": " + e.getMessage())); + System.out.println(" " + dc.name + ": ERROR - " + e.getMessage()); + } + fromDataResults.add(entry); + } + } + + private static Map validate(String promptText) { + try { + FilledParamData result = + server.validateNotificationPromptAndDataFilling(promptText, validationSchema, templateUri); + Map output = new LinkedHashMap<>(); + output.put("semantic_verdict", true); + output.put("params", result.data()); + return output; + } catch (ContentValidationException e) { + Map output = new LinkedHashMap<>(); + output.put("semantic_verdict", false); + output.put("code", e.getCode()); + output.put("message", e.getMessage()); + if (!e.errors().isEmpty()) { + output.put( + "errors", + e.errors().stream() + .map(err -> { + Map item = new LinkedHashMap(); + item.put("slot_name", err.slotName()); + item.put("code", err.code()); + item.put("message", err.message()); + item.put("facts", err.facts()); + return item; + }) + .toList()); + } + return output; + } catch (Exception e) { + return Map.of("error", e.getClass().getName() + ": " + e.getMessage()); + } + } + + @Test + void validateNotificationPromptAndDataFilling() throws Exception { + if (!apiKeyConfigured) { + System.out.println("SKIP: validateNotificationPromptAndDataFilling - API key not configured"); + return; + } + List cases = + loadValidateCases("service-recovery-accuracy-test/test-cases-validate-and-fill.json"); + System.out.println("== validateNotificationPromptAndDataFilling =="); + boolean first = true; + for (ValidateCase vc : cases) { + if (!first) System.out.println("---"); + first = false; + Map entry = new LinkedHashMap<>(); + entry.put("name", vc.name); + entry.put("input", Map.of("promptText", vc.promptText)); + Map output = validate(vc.promptText); + entry.put("output", output); + System.out.println(" " + vc.name + ": " + MAPPER.writeValueAsString(output)); + validateResults.add(entry); + } + } + + private static Map metadataOutput(MetadataContent result) { + Map output = new LinkedHashMap<>(); + output.put("templateUri", result.templateUri()); + output.put("extensionUri", result.extensionUri()); + output.put("promptText", result.promptText()); + return output; + } + + private static String compact(String text) { + if (text == null || text.isEmpty()) { + return text; + } + return text.replaceAll("(?m)^[ \t]*\\r?\\n", ""); + } + + private static Path resolveEnvFile(String resourcePath) throws IOException, URISyntaxException { + URL url = NotificationTAccuracyTest.class.getClassLoader().getResource(resourcePath); + Objects.requireNonNull(url, "Resource not found: " + resourcePath); + Path envPath = Path.of(url.toURI()); + Path projectRoot = findProjectRoot(envPath.getParent()); + + String content = Files.readString(envPath, StandardCharsets.UTF_8); + String resolvedContent = resolveRootDir(content, projectRoot); + + Path tempFile = Files.createTempFile("notification-t-test-", ".env"); + Files.writeString(tempFile, resolvedContent, StandardCharsets.UTF_8); + tempFile.toFile().deleteOnExit(); + return tempFile; + } + + private static Path findProjectRoot(Path start) { + Path dir = start; + while (dir != null) { + if (Files.isDirectory(dir.resolve("a2a-t-resources"))) { + return dir; + } + dir = dir.getParent(); + } + throw new IllegalStateException("Project root (containing a2a-t-resources) not found above " + start); + } + + private static String resolveRootDir(String content, Path projectRoot) { + String prefix = "A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR="; + Path promptRoot = projectRoot + .resolve("a2a-t-resources/src/main/resources/prompt_resources") + .normalize(); + String absolutePath = promptRoot.toString(); + for (String line : content.split("\n")) { + String trimmed = line.strip(); + if (trimmed.startsWith(prefix)) { + return content.replace(trimmed, prefix + absolutePath); + } + } + return content; + } + + private static Map readEnvInfo(Path envPath) throws IOException { + Map info = new LinkedHashMap<>(); + for (String line : Files.readAllLines(envPath, StandardCharsets.UTF_8)) { + String trimmed = line.strip(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int eqIdx = trimmed.indexOf('='); + if (eqIdx > 0) { + String key = trimmed.substring(0, eqIdx).strip(); + String value = trimmed.substring(eqIdx + 1).strip(); + if (key.equals("A2AT_LLM_API_KEY")) { + value = value.isBlank() ? "" : "***"; + } + info.put(key, value); + } + } + return info; + } + + private static Map loadJsonResource(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private static List loadTextCases(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private static List loadDataCases(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private static List loadValidateCases(String resourcePath) throws IOException { + try (InputStream is = NotificationTAccuracyTest.class.getClassLoader().getResourceAsStream(resourcePath)) { + Objects.requireNonNull(is, "Resource not found: " + resourcePath); + return MAPPER.readValue(is, new TypeReference>() {}); + } + } + + private record TextCase(String name, String text) {} + + private record DataCase(String name, Map data) {} + + private record ValidateCase(String name, String promptText) {} +} 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..b7ed91dd --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/service_recovery/server/runtime/DefaultSampleServerRuntimeTest.java @@ -0,0 +1,88 @@ +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..213ea18c --- /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)); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/server/runtime/DefaultSampleServerRuntimeTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntimeTest.java similarity index 96% rename from a2a-t-sample/src/test/java/net/openan/a2at/sample/server/runtime/DefaultSampleServerRuntimeTest.java rename to a2a-t-sample/src/test/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntimeTest.java index b1d35872..cc6f2960 100644 --- a/a2a-t-sample/src/test/java/net/openan/a2at/sample/server/runtime/DefaultSampleServerRuntimeTest.java +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/subscribe_incident/server/runtime/DefaultSampleServerRuntimeTest.java @@ -1,4 +1,4 @@ -package net.openan.a2at.sample.server.runtime; +package net.openan.a2at.sample.subscribe_incident.server.runtime; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluatorTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluatorTest.java new file mode 100644 index 00000000..3e83ba19 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTAccuracyEvaluatorTest.java @@ -0,0 +1,218 @@ +package net.openan.a2at.sample.task_t; + +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 org.junit.jupiter.api.Test; + +/** + * Unit tests for the field/sample scoring logic of the Task-T accuracy demo. + * + *

      Two match modes exist: {@link TaskTAccuracyEvaluator.MatchMode#EXACT} applies to structured identifier fields + * (access port, scenario, time, serial) where a truncated or partially matching value must NOT score a hit; and + * {@link TaskTAccuracyEvaluator.MatchMode#CONTAINS} applies to the free-text complaint detail where the extracted value + * and the expected keyword are allowed to contain each other after normalization. + * + *

      The match mode is now determined by the slot name rather than passed explicitly. Use + * {@link TaskTPrivateLineComplaintSamples#SERVER_PORT} for EXACT mode tests and + * {@link TaskTPrivateLineComplaintSamples#SERVER_DETAIL} for CONTAINS mode tests. + */ +class TaskTAccuracyEvaluatorTest { + + private static final String PORT = "P781-KXVN-PTN7900-23-TPA1EG24-17"; + private static final String EXACT_SLOT = TaskTPrivateLineComplaintSamples.SERVER_PORT; + private static final String CONTAINS_SLOT = TaskTPrivateLineComplaintSamples.SERVER_DETAIL; + + // --------------------------------------------------------------------- + // matches(): EXACT mode (via SERVER_PORT slot) + // --------------------------------------------------------------------- + + @Test + void exact_shouldHit_WhenValuesEqualAfterNormalization() { + assertTrue(TaskTAccuracyEvaluator.matches(PORT, PORT, EXACT_SLOT)); + // whitespace and case are ignored before comparison + assertTrue(TaskTAccuracyEvaluator.matches("event-Id-20260511-09013", "EVENT-ID-20260511-09013", EXACT_SLOT)); + } + + @Test + void exact_shouldNotHit_WhenExtractedIsOnlyAPrefixOfExpected() { + // the port identifier must be extracted in full; a bare "P781" prefix must not score a hit + assertFalse(TaskTAccuracyEvaluator.matches("P781", PORT, EXACT_SLOT)); + } + + @Test + void exact_shouldNotHit_WhenExtractedContainsExpectedButIsLonger() { + // "connect timeout..." is not equal to the expected serial — trailing content must not pass as equal + assertFalse( + TaskTAccuracyEvaluator.matches("event-id-20260511-09013-ext", "event-id-20260511-09013", EXACT_SLOT)); + } + + @Test + void exact_shouldNotHit_WhenValuesAreSimilarButNotEqual() { + assertFalse(TaskTAccuracyEvaluator.matches("专线掉线", "专线中断", EXACT_SLOT)); + assertFalse(TaskTAccuracyEvaluator.matches("2026-05-11T08:21:46", "2026-05-11", EXACT_SLOT)); + } + + // --------------------------------------------------------------------- + // matches(): CONTAINS mode (free-text complaint detail, via SERVER_DETAIL slot) + // --------------------------------------------------------------------- + + @Test + void contains_shouldHit_WhenExtractedDetailMentionsTheExpectedKeyword() { + String detail = "专线时延抖动明显,平均丢包率约5%、峰值达15%,视频会议频繁卡顿。"; + assertTrue(TaskTAccuracyEvaluator.matches(detail, "丢包", CONTAINS_SLOT)); + assertTrue(TaskTAccuracyEvaluator.matches(detail, "时延", CONTAINS_SLOT)); + } + + @Test + void contains_shouldHit_WhenExpectedKeywordContainsTheExtractedValue() { + // expected ground truth is a longer phrase that happens to contain the extracted words + assertTrue(TaskTAccuracyEvaluator.matches("时延抖动", "专线时延抖动明显,峰值达15%", CONTAINS_SLOT)); + } + + @Test + void contains_shouldNotHit_WhenKeywordIsAbsent() { + assertFalse(TaskTAccuracyEvaluator.matches("专线完全中断,流量归零。", "丢包", CONTAINS_SLOT)); + } + + // --------------------------------------------------------------------- + // matches(): empty / null handling + // --------------------------------------------------------------------- + + @Test + void shouldNotHit_WhenExtractedIsNullOrBlank() { + assertFalse(TaskTAccuracyEvaluator.matches(null, PORT, EXACT_SLOT)); + assertFalse(TaskTAccuracyEvaluator.matches("", PORT, EXACT_SLOT)); + assertFalse(TaskTAccuracyEvaluator.matches(" ", PORT, EXACT_SLOT)); + } + + @Test + void shouldNotHit_WhenExpectedIsBlank() { + assertFalse(TaskTAccuracyEvaluator.matches(PORT, "", EXACT_SLOT)); + } + + // --------------------------------------------------------------------- + // matches(): date normalization (faultTime slot) + // --------------------------------------------------------------------- + + private static final String TIME_SLOT = TaskTPrivateLineComplaintSamples.SERVER_TIME; + + @Test + void time_shouldHit_WhenIsoMatchesChineseMonthDay() { + // ISO with timezone offset vs Chinese month-day format + assertTrue(TaskTAccuracyEvaluator.matches("2026-05-11T08:30:00+08:00", "5月11号", TIME_SLOT)); + // ISO without timezone vs Chinese format + assertTrue(TaskTAccuracyEvaluator.matches("2026-05-11T08:30:00", "5月11号", TIME_SLOT)); + // ISO with Z suffix vs Chinese format + assertTrue(TaskTAccuracyEvaluator.matches("2026-06-08T15:00:00Z", "6月8号", TIME_SLOT)); + } + + @Test + void time_shouldHit_WhenBothAreSameFormat() { + // both Chinese format + assertTrue(TaskTAccuracyEvaluator.matches("5月11号", "5月11号", TIME_SLOT)); + // both ISO format + assertTrue(TaskTAccuracyEvaluator.matches("2026-05-11T08:30:00", "2026-05-11", TIME_SLOT)); + } + + @Test + void time_shouldNotHit_WhenMonthDayDiffers() { + assertFalse(TaskTAccuracyEvaluator.matches("2026-05-11T08:30:00", "5月12号", TIME_SLOT)); + assertFalse(TaskTAccuracyEvaluator.matches("2026-06-08T15:00:00Z", "6月9号", TIME_SLOT)); + } + + // --------------------------------------------------------------------- + // scoreFields(): per-field mode selection + // --------------------------------------------------------------------- + + @Test + void scoreFields_shouldScoreStructuredFieldsExactly_AndDetailByContains() { + TaskTSample sample = new TaskTSample( + "text-prefix-leak", + "text", + null, + null, + Map.of( + TaskTPrivateLineComplaintSamples.SERVER_PORT, PORT, + TaskTPrivateLineComplaintSamples.SERVER_SCENARIO, "专线质差", + TaskTPrivateLineComplaintSamples.SERVER_TICKET, "event-id-20260511-09013", + TaskTPrivateLineComplaintSamples.SERVER_DETAIL, "丢包"), + Map.of()); + + // extracted accessPort is truncated to the prefix; detail is a full sentence mentioning the keyword + Map extracted = Map.of( + TaskTPrivateLineComplaintSamples.SERVER_PORT, "P781", + TaskTPrivateLineComplaintSamples.SERVER_SCENARIO, "专线质差", + TaskTPrivateLineComplaintSamples.SERVER_TICKET, "event-id-20260511-09013", + TaskTPrivateLineComplaintSamples.SERVER_DETAIL, "专线时延抖动明显,丢包峰值达15%"); + + List fields = TaskTAccuracyEvaluator.scoreFields(sample, extracted); + + assertEquals(4, fields.size()); + assertFalse( + field(fields, TaskTPrivateLineComplaintSamples.SERVER_PORT).matched(), + "truncated port prefix must not hit"); + assertTrue( + field(fields, TaskTPrivateLineComplaintSamples.SERVER_SCENARIO).matched(), "exact scenario must hit"); + assertTrue(field(fields, TaskTPrivateLineComplaintSamples.SERVER_TICKET).matched(), "exact serial must hit"); + assertTrue( + field(fields, TaskTPrivateLineComplaintSamples.SERVER_DETAIL).matched(), + "detail containing the keyword must hit"); + } + + private static TaskTAccuracyEvaluator.FieldScore field( + List fields, String slot) { + return fields.stream().filter(f -> f.slot().equals(slot)).findFirst().orElseThrow(); + } + + @Test + void sampleScore_shouldPass_OnlyWhenEveryFieldHit() { + Map expected = Map.of( + TaskTPrivateLineComplaintSamples.SERVER_PORT, + PORT, + TaskTPrivateLineComplaintSamples.SERVER_DETAIL, + "丢包"); + TaskTSample sample = new TaskTSample("hit-all", "text", null, null, expected, Map.of()); + + Map fullExtraction = Map.of( + TaskTPrivateLineComplaintSamples.SERVER_PORT, + PORT, + TaskTPrivateLineComplaintSamples.SERVER_DETAIL, + "故障现象:丢包率最高15%。"); + TaskTAccuracyEvaluator.SampleScore pass = new TaskTAccuracyEvaluator.SampleScore( + sample.name(), true, TaskTAccuracyEvaluator.scoreFields(sample, fullExtraction)); + assertTrue(pass.passed()); + + Map brokenExtraction = Map.of( + TaskTPrivateLineComplaintSamples.SERVER_PORT, "P781", + TaskTPrivateLineComplaintSamples.SERVER_DETAIL, "故障现象:丢包率最高15%。"); + TaskTAccuracyEvaluator.SampleScore fail = new TaskTAccuracyEvaluator.SampleScore( + sample.name(), true, TaskTAccuracyEvaluator.scoreFields(sample, brokenExtraction)); + assertFalse(fail.passed()); + } + + @Test + void summarize_shouldAggregateMatchedFieldsAndPassedSamples() { + Map expected = Map.of(TaskTPrivateLineComplaintSamples.SERVER_PORT, PORT); + TaskTSample sample = new TaskTSample("agg", "text", null, null, expected, Map.of()); + + Map oneHit = Map.of(TaskTPrivateLineComplaintSamples.SERVER_PORT, PORT); + List scores = List.of( + new TaskTAccuracyEvaluator.SampleScore( + "agg-1", true, TaskTAccuracyEvaluator.scoreFields(sample, oneHit)), + new TaskTAccuracyEvaluator.SampleScore( + "agg-2", true, TaskTAccuracyEvaluator.scoreFields(sample, Map.of()))); + + TaskTAccuracyEvaluator.Summary summary = TaskTAccuracyEvaluator.summarize("api", scores); + + assertEquals(2, summary.sampleCount()); + assertEquals(1, summary.passedSamples()); + assertEquals(1, summary.matchedFields()); + assertEquals(2, summary.expectedFields()); + assertEquals(50.0d, summary.fieldAccuracyPercent(), 1e-6); + assertEquals(50.0d, summary.samplePassRatePercent(), 1e-6); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamplesTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamplesTest.java new file mode 100644 index 00000000..fa6c8cc1 --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTPrivateLineComplaintSamplesTest.java @@ -0,0 +1,66 @@ +package net.openan.a2at.sample.task_t; + +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 org.junit.jupiter.api.Test; + +/** + * Guards the packaged sample file {@code private-line-complaint-samples.json}: sample counts per case, the contract + * {@code enum} on the scenario fields of both schemas, and the optional-slot observation samples (which must not expect + * the optional time/serial fields). + */ +class TaskTPrivateLineComplaintSamplesTest { + + @Test + void should_Load14AccuracySamples_7TextAnd7Data() { + assertEquals(7, TaskTPrivateLineComplaintSamples.textSamples().size()); + assertEquals(7, TaskTPrivateLineComplaintSamples.dataWithSchemaSamples().size()); + } + + @Test + void should_Load8RejectionSamples() { + assertEquals(8, TaskTPrivateLineComplaintSamples.rejectionSamples().size()); + } + + @Test + void should_ContractScenarioEnum_OnBothSchemas() { + Map semantics = + TaskTPrivateLineComplaintSamples.dataWithSchemaSamples().get(0).semanticsSchema(); + Map validation = + TaskTPrivateLineComplaintSamples.dataWithSchemaSamples().get(0).validationSchema(); + + assertEquals(List.of("专线中断", "专线质差"), enumOf(semantics, "complaintScenario")); + assertEquals(List.of("专线中断", "专线质差"), enumOf(validation, "bizScenario")); + } + + @Test + void should_OptionalSlotObservationSamples_NotExpectTimeOrSerial() { + TaskTSample text = sampleByName(TaskTPrivateLineComplaintSamples.textSamples(), "text-optional-slots-missing"); + TaskTSample data = + sampleByName(TaskTPrivateLineComplaintSamples.dataWithSchemaSamples(), "data-optional-slots-missing"); + + assertFalse(text.expectedParams().containsKey(TaskTPrivateLineComplaintSamples.SERVER_TIME)); + assertFalse(text.expectedParams().containsKey(TaskTPrivateLineComplaintSamples.SERVER_TICKET)); + assertFalse(data.expectedParams().containsKey(TaskTPrivateLineComplaintSamples.SERVER_TIME)); + assertFalse(data.expectedParams().containsKey(TaskTPrivateLineComplaintSamples.SERVER_TICKET)); + assertTrue(text.expectedParams().containsKey(TaskTPrivateLineComplaintSamples.SERVER_PORT)); + assertTrue(data.expectedParams().containsKey(TaskTPrivateLineComplaintSamples.SERVER_PORT)); + } + + private static List enumOf(Map schema, String property) { + Map properties = (Map) schema.get("properties"); + Map field = (Map) properties.get(property); + return (List) field.get("enum"); + } + + private static TaskTSample sampleByName(List samples, String name) { + return samples.stream() + .filter(sample -> sample.name().equals(name)) + .findFirst() + .orElse(null); + } +} diff --git a/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java new file mode 100644 index 00000000..ae74be3a --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sample/task_t/TaskTSingleSampleTest.java @@ -0,0 +1,466 @@ +package net.openan.a2at.sample.task_t; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.exception.PromptGenerationException; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +/** + * 集成测试:将单个 Task-T 样本走通 client → server 完整链路。 + * + *

      所有测试方法默认 {@code @Disabled},需要 LLM 环境({@code client.env} 配置了可用的 OpenAI 兼容接口)。 在 IDE 中手动取消对应方法的 {@code @Disabled} + * 即可单独调试某一个样本。 + * + *

      样本来源:{@link TaskTPrivateLineComplaintSamples}(从 {@code private-line-complaint-samples.json} 加载)。 + * + *

      用法

      + * + *
      {@code
      + * // 1. 把要调试的样本方法的 @Disabled 注释掉
      + * // 2. 在 IDE 中右键运行该方法
      + * // 3. 查看控制台输出的 prompt / 提取参数 / 字段命中详情
      + * }
      + */ +@Disabled("需要 LLM 环境,默认不执行;在 IDE 中手动取消此注解即可运行") +class TaskTSingleSampleTest { + + private static final String DEFAULT_ENV = "client.env"; + + /** Classpath 资源路径,对应打包的 {@code sample/task_t/client.env}。 */ + private static final String BUNDLED_ENV_RESOURCE = "sample/task_t/client.env"; + + private static A2ATClient client; + private static A2ATServer server; + private static Path tempEnvFile; + + @BeforeAll + static void setUp() throws IOException { + Path envPath = resolveEnvPath(); + client = new A2ATClient(envPath); + server = new A2ATServer(envPath); + } + + @AfterAll + static void tearDown() throws IOException { + if (tempEnvFile != null) { + Files.deleteIfExists(tempEnvFile); + } + } + + // ========================================================================= + // 用例一:generateTaskPromptFromText(自然语言文本样本) + // ========================================================================= + + /** + * 文本样本:专线质差投诉(包含所有 5 个槽位)。 + * + *

      输入为一段口语化的自然语言投诉描述,期望 LLM 能提取出接入端口、投诉场景、故障时间、流水号、故障详情。 + */ + @Test + void textSample_privateLineQuality() { + TaskTSample sample = textSample("text-private-line-quality"); + runAndAssert(sample); + } + + /** 文本样本:逻辑端口专线中断投诉。 */ + @Test + void textSample_logicalPortInterruption() { + TaskTSample sample = textSample("text-logical-port-interruption"); + runAndAssert(sample); + } + + /** 文本样本:端口质差抖动投诉。 */ + @Test + void textSample_portQualityJitter() { + TaskTSample sample = textSample("text-port-quality-jitter"); + runAndAssert(sample); + } + + /** 文本样本:端口中断路由投诉。 */ + @Test + void textSample_portInterruptionRoute() { + TaskTSample sample = textSample("text-port-interruption-route"); + runAndAssert(sample); + } + + /** 文本样本:端口质差时延投诉。 */ + @Test + void textSample_portQualityLatency() { + TaskTSample sample = textSample("text-port-quality-latency"); + runAndAssert(sample); + } + + /** 文本样本:逻辑端口中断 VLAN 投诉。 */ + @Test + void textSample_logicalPortInterruptionVlan() { + TaskTSample sample = textSample("text-logical-port-interruption-vlan"); + runAndAssert(sample); + } + + /** 文本样本:可选槽位缺失(无时间、无流水号),验证服务端对可选字段不强制要求。 */ + @Test + void textSample_optionalSlotsMissing() { + TaskTSample sample = textSample("text-optional-slots-missing"); + runAndAssert(sample); + } + + // ========================================================================= + // 用例二:generateTaskPromptFromDataWithSchema(结构化数据 + 语义 schema) + // ========================================================================= + + /** 数据样本:端口质差(结构化输入,客户端 key → 服务端 key 跨键适配)。 */ + @Test + void dataSample_portQuality() { + TaskTSample sample = dataSample("data-port-quality"); + runDataAndAssert(sample); + } + + /** 数据样本:逻辑端口中断。 */ + @Test + void dataSample_logicalPortInterruption() { + TaskTSample sample = dataSample("data-logical-port-interruption"); + runDataAndAssert(sample); + } + + /** 数据样本:端口抖动。 */ + @Test + void dataSample_portJitter() { + TaskTSample sample = dataSample("data-port-jitter"); + runDataAndAssert(sample); + } + + /** 数据样本:逻辑端口中断路由。 */ + @Test + void dataSample_logicalPortInterruptionRoute() { + TaskTSample sample = dataSample("data-logical-port-interruption-route"); + runDataAndAssert(sample); + } + + /** 数据样本:端口质差丢包。 */ + @Test + void dataSample_portQualityLoss() { + TaskTSample sample = dataSample("data-port-quality-loss"); + runDataAndAssert(sample); + } + + /** 数据样本:端口质差核心慢。 */ + @Test + void dataSample_portQualityCoreSlow() { + TaskTSample sample = dataSample("data-port-quality-core-slow"); + runDataAndAssert(sample); + } + + /** + * 数据样本:可选槽位缺失(无时间、无流水号),验证服务端对可选字段不强制要求。 + * + *

      已禁用:LLM 偶发不提取 faultDetail,导致非确定性失败。 + */ + @Test + @Disabled("LLM 偶发不提取 faultDetail,结果不稳定") + void dataSample_optionalSlotsMissing() { + TaskTSample sample = dataSample("data-optional-slots-missing"); + runDataAndAssert(sample); + } + + // ========================================================================= + // 用例三:拒绝样本(缺少关键槽位,期望服务端校验拒绝) + // ========================================================================= + + /** 文本拒绝样本:缺少接入端口。 */ + @Test + void rejectionSample_textMissingAccessPort() { + TaskTRejectionSample sample = rejectionSample("text-missing-access-port"); + runRejectionAndAssert(sample, false); + } + + /** 文本拒绝样本:缺少投诉场景。 */ + @Test + void rejectionSample_textMissingScenario() { + TaskTRejectionSample sample = rejectionSample("text-missing-scenario"); + runRejectionAndAssert(sample, false); + } + + /** 文本拒绝样本:缺少所有关键字段。 */ + @Test + void rejectionSample_textMinimalNoKeyFields() { + TaskTRejectionSample sample = rejectionSample("text-minimal-no-key-fields"); + runRejectionAndAssert(sample, false); + } + + /** 文本拒绝样本:缺少场景和流水号。 */ + @Test + void rejectionSample_textMissingScenarioAndSerial() { + TaskTRejectionSample sample = rejectionSample("text-missing-scenario-and-serial"); + runRejectionAndAssert(sample, false); + } + + /** 数据拒绝样本:缺少端口。 */ + @Test + void rejectionSample_dataMissingPort() { + TaskTRejectionSample sample = rejectionSample("data-missing-port"); + runRejectionAndAssert(sample, true); + } + + /** 数据拒绝样本:缺少投诉场景。 */ + @Test + void rejectionSample_dataMissingScenario() { + TaskTRejectionSample sample = rejectionSample("data-missing-scenario"); + runRejectionAndAssert(sample, true); + } + + /** + * 数据拒绝样本:投诉场景值不在枚举合约内。 + * + *

      已禁用:LLM 总会从 faultDetailText 等上下文推断并规范化 complaintScenario, 无法可靠触发枚举值校验拒绝。 + */ + @Test + @Disabled("LLM 会从上下文推断并规范化非法枚举值,无法可靠触发拒绝") + void rejectionSample_dataInvalidScenarioValue() { + TaskTRejectionSample sample = rejectionSample("data-invalid-scenario-value"); + runRejectionAndAssert(sample, true); + } + + /** 数据拒绝样本:缺少端口和场景。 */ + @Test + void rejectionSample_dataMissingPortAndScenario() { + TaskTRejectionSample sample = rejectionSample("data-missing-port-and-scenario"); + runRejectionAndAssert(sample, true); + } + + // ========================================================================= + // 内部方法 + // ========================================================================= + + /** 运行一个文本 accuracy 样本:generateTaskPromptFromText → validate → scoreFields → 断言全部字段命中。 */ + private void runAndAssert(TaskTSample sample) { + System.out.println("─── 样本: " + sample.name() + " (text) ───"); + System.out.println("[输入] " + sample.text()); + System.out.println(); + + MetadataContent metadata = + client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + printMetadata(metadata); + + Map extracted = validate(server, sample, metadata); + printExtracted(extracted); + + List fields = TaskTAccuracyEvaluator.scoreFields(sample, extracted); + printFieldScores(fields); + + TaskTAccuracyEvaluator.SampleScore score = new TaskTAccuracyEvaluator.SampleScore(sample.name(), true, fields); + assertTrue( + score.passed(), + () -> "样本 " + sample.name() + " 未通过: " + score.matchedFieldCount() + "/" + score.expectedFieldCount() + + " 字段命中"); + } + + /** 运行一个数据 accuracy 样本:generateTaskPromptFromDataWithSchema → validate → scoreFields → 断言全部字段命中。 */ + private void runDataAndAssert(TaskTSample sample) { + System.out.println("─── 样本: " + sample.name() + " (data) ───"); + System.out.println("[输入] data: " + sample.data()); + System.out.println("[输入] schema: " + sample.semanticsSchema()); + System.out.println(); + + MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + printMetadata(metadata); + + Map extracted = validate(server, sample, metadata); + printExtracted(extracted); + + List fields = TaskTAccuracyEvaluator.scoreFields(sample, extracted); + printFieldScores(fields); + + TaskTAccuracyEvaluator.SampleScore score = new TaskTAccuracyEvaluator.SampleScore(sample.name(), true, fields); + assertTrue( + score.passed(), + () -> "样本 " + sample.name() + " 未通过: " + score.matchedFieldCount() + "/" + score.expectedFieldCount() + + " 字段命中"); + } + + /** + * 运行一个拒绝样本:期望被服务端语义校验拒绝,或被客户端前置校验拒绝。 + * + * @param isData {@code true} 表示数据样本(走 {@code generateTaskPromptFromDataWithSchema}), {@code false} 表示文本样本(走 + * {@code generateTaskPromptFromText}) + */ + private void runRejectionAndAssert(TaskTRejectionSample sample, boolean isData) { + System.out.println("─── 拒绝样本: " + sample.name() + " (" + (isData ? "data" : "text") + ") ───"); + if (isData) { + System.out.println("[输入] data: " + sample.data()); + System.out.println("[输入] schema: " + sample.semanticsSchema()); + } else { + System.out.println("[输入] " + sample.text()); + } + System.out.println(); + + MetadataContent metadata; + try { + if (isData) { + metadata = client.generateTaskPromptFromDataWithSchema( + sample.data(), sample.semanticsSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + } else { + metadata = + client.generateTaskPromptFromText(sample.text(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); + } + } catch (PromptGenerationException e) { + // 客户端前置校验拒绝:缺少必填槽位,在调用 LLM 之前就被拦截 + System.out.println("[客户端前置拒绝] [" + e.getCode() + "] " + e.getMessage()); + return; // 预期被拒绝,测试通过 + } + printMetadata(metadata); + + try { + Map extracted = server.validateTaskPromptAndDataFilling( + metadata.promptText(), + sample.validationSchema(), + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) + .data(); + System.out.println("[意外通过] 应被拒绝却校验通过,提取参数: " + extracted); + // 拒绝样本应被拒绝,意外通过视为失败 + throw new AssertionError("拒绝样本 " + sample.name() + " 应被服务端拒绝,但校验通过了"); + } catch (ContentValidationException e) { + System.out.println("[正确拒绝] [" + e.getCode() + "] " + e.getMessage()); + List errors = e.errors(); + if (errors != null && !errors.isEmpty()) { + for (net.openan.a2at.sdk.core.model.SlotValidationError error : errors) { + System.out.println( + " slot=" + error.slotName() + " code=" + error.code() + " message=" + error.message()); + } + } + // 期望被拒绝,测试通过 + } + } + + private static Map validate(A2ATServer server, TaskTSample sample, MetadataContent metadata) { + return server.validateTaskPromptAndDataFilling( + metadata.promptText(), sample.validationSchema(), StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) + .data(); + } + + private static void printMetadata(MetadataContent metadata) { + System.out.println("[生成] extensionUri: " + metadata.extensionUri()); + System.out.println("[生成] templateUri : " + metadata.templateUri()); + System.out.println("[生成] promptText :"); + System.out.println(metadata.promptText()); + System.out.println(); + } + + private static void printExtracted(Map extracted) { + System.out.println("[校验] 服务端 validateTaskPromptAndDataFilling 通过,提取参数:"); + if (extracted.isEmpty()) { + System.out.println(" (空)"); + } else { + extracted.forEach((k, v) -> System.out.println(" " + k + " = " + v)); + } + System.out.println(); + } + + private static void printFieldScores(List fields) { + System.out.println("[比对] 字段级命中:"); + for (TaskTAccuracyEvaluator.FieldScore field : fields) { + String mark = field.matched() ? "✔ 命中" : "✘ 未命中"; + String detail = field.matched() ? "" : " " + field.detail(); + System.out.println(" " + mark + " " + field.slot() + detail); + } + System.out.println(); + } + + private static TaskTSample textSample(String name) { + return sampleByName(TaskTPrivateLineComplaintSamples.textSamples(), name); + } + + private static TaskTSample dataSample(String name) { + return sampleByName(TaskTPrivateLineComplaintSamples.dataWithSchemaSamples(), name); + } + + private static TaskTRejectionSample rejectionSample(String name) { + return TaskTPrivateLineComplaintSamples.rejectionSamples().stream() + .filter(sample -> sample.name().equals(name)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("未找到拒绝样本: " + name)); + } + + private static TaskTSample sampleByName(List samples, String name) { + return samples.stream() + .filter(sample -> sample.name().equals(name)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("未找到样本: " + name)); + } + + /** + * 解析 {@code client.env} 路径。 + * + *

      优先级: + * + *

        + *
      1. 工作目录下的 {@code client.env}(需包含有效的 LLM 配置密钥); + *
      2. repo 根目录下的 {@code client.env}(Maven 多模块项目,从模块目录向上一级); + *
      3. classpath 中打包的 sample 模板,复制到临时文件。 + *
      + */ + private static Path resolveEnvPath() throws IOException { + Path cwdEnv = Path.of(DEFAULT_ENV); + if (Files.exists(cwdEnv) && hasRequiredLlmKeys(cwdEnv)) { + return cwdEnv; + } + // 从 a2a-t-sample 模块目录向上一级就是 repo 根目录 + Path repoRootEnv = Path.of("..", DEFAULT_ENV); + if (Files.exists(repoRootEnv) && hasRequiredLlmKeys(repoRootEnv)) { + return repoRootEnv; + } + // 最后回退到 classpath 中的打包模板 + ClassLoader classLoader = TaskTSingleSampleTest.class.getClassLoader(); + try (InputStream in = classLoader.getResourceAsStream(BUNDLED_ENV_RESOURCE)) { + if (in == null) { + throw new IllegalStateException("classpath 中未找到打包的 env 模板: " + BUNDLED_ENV_RESOURCE); + } + tempEnvFile = Files.createTempFile("taskt-client-", ".env"); + Files.copy(in, tempEnvFile, StandardCopyOption.REPLACE_EXISTING); + return tempEnvFile; + } + } + + 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 (java.io.IOException e) { + return false; + } + for (String key : List.of("A2AT_LLM_PROVIDER", "A2AT_LLM_MODEL", "A2AT_LLM_API_KEY")) { + if (entries.getOrDefault(key, "").isBlank()) { + return false; + } + } + return true; + } +} 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 new file mode 100644 index 00000000..cb5371ab --- /dev/null +++ b/a2a-t-sample/src/test/java/net/openan/a2at/sdk/sample/api/NegotiationV3ApiSurfaceTest.java @@ -0,0 +1,164 @@ +package net.openan.a2at.sdk.sample.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; +import java.lang.reflect.RecordComponent; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.Test; + +/** + * Locks the v3 negotiation API surface of both facades and the field faces of the result records. + * + *

      The client and the server expose exactly the twelve camelCase negotiation methods with their pinned parameter + * counts, their surface carries none of the removed v2-shape method names nor the removed negotiation query methods, + * and the result records expose exactly their documented components. + */ +class NegotiationV3ApiSurfaceTest { + + private static final Map EXPECTED_NEGOTIATION_METHOD_PARAMETERS = Map.ofEntries( + Map.entry("generateNegotiationProposePromptFromData", 2), + Map.entry("generateNegotiationAcceptPromptFromData", 2), + Map.entry("generateNegotiationRejectPromptFromData", 2), + Map.entry("generateNegotiationAbortPromptFromData", 2), + Map.entry("generateNegotiationProposePromptFromText", 3), + Map.entry("generateNegotiationAcceptPromptFromText", 3), + Map.entry("generateNegotiationRejectPromptFromText", 3), + Map.entry("generateNegotiationAbortPromptFromText", 3), + Map.entry("validateProposePromptAndDataFilling", 4), + Map.entry("validateAcceptPromptAndDataFilling", 4), + Map.entry("validateRejectPromptAndDataFilling", 4), + Map.entry("validateAbortPromptAndDataFilling", 4)); + + private static final List REMOVED_NEGOTIATION_QUERY_METHODS = + List.of("getNegotiationPrompts", "getNegotiationPrompt"); + + private static final List CROSS_EXTENSION_QUERY_METHODS = List.of("getPrompts", "getPrompt"); + + private static final List REMOVED_V2_METHOD_NAME_FRAGMENTS = + List.of("FromNl", "FromJsonData", "validateAndExtractParams"); + + @Test + void bothFacadesExposeExactlyTheTwelveV3NegotiationMethods() { + assertExactNegotiationSurface(A2ATClient.class); + assertExactNegotiationSurface(A2ATServer.class); + } + + @Test + void removedNegotiationQueryMethodsAreAbsentWhileCrossExtensionQueriesRemain() { + for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { + for (String removedMethod : REMOVED_NEGOTIATION_QUERY_METHODS) { + assertFalse( + Arrays.stream(facade.getMethods()) + .anyMatch(method -> method.getName().equals(removedMethod)), + facade.getSimpleName() + " must not expose the removed " + removedMethod); + } + for (String queryMethod : CROSS_EXTENSION_QUERY_METHODS) { + assertTrue( + Arrays.stream(facade.getMethods()) + .anyMatch(method -> method.getName().equals(queryMethod)), + facade.getSimpleName() + " must expose the cross-extension query " + queryMethod); + } + } + } + + @Test + void neitherFacadeCarriesRemovedV2ShapeMethodNames() { + assertNoV2ShapeNames(A2ATClient.class); + assertNoV2ShapeNames(A2ATServer.class); + } + + @Test + void resultRecordsExposeTheirPinnedFieldFaces() { + List metadataComponents = componentNames(MetadataContent.class); + assertEquals(List.of("templateUri", "promptText", "extensionUri", "negotiationContext"), metadataComponents); + List contextComponents = componentNames(NegotiationContext.class); + assertEquals(List.of("id", "round", "maxRounds", "performative"), contextComponents); + List filledComponents = componentNames(FilledParamData.class); + assertEquals(List.of("data"), filledComponents); + assertTrue( + Arrays.stream(MetadataContent.class.getMethods()) + .anyMatch(method -> method.getName().equals("buildMetadataContent")), + "MetadataContent must expose buildMetadataContent"); + } + + @Test + void allFacadeTemplateUriParamsAreStrings() { + for (Class facade : List.of(A2ATClient.class, A2ATServer.class)) { + List methodsWithTemplateUri = Arrays.stream(facade.getDeclaredMethods()) + .filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers()) + && !java.lang.reflect.Modifier.isStatic(method.getModifiers())) + .filter(method -> Arrays.stream(method.getParameters()) + .anyMatch(parameter -> "templateUri".equals(parameter.getName()))) + .toList(); + assertFalse(methodsWithTemplateUri.isEmpty(), facade.getSimpleName() + " must declare templateUri methods"); + for (Method method : methodsWithTemplateUri) { + for (java.lang.reflect.Parameter parameter : method.getParameters()) { + if ("templateUri".equals(parameter.getName())) { + assertEquals( + String.class, + parameter.getType(), + "templateUri parameter of " + facade.getSimpleName() + "." + method.getName()); + } + } + } + } + } + + private static void assertExactNegotiationSurface(Class facade) { + Method[] allMethods = facade.getMethods(); + List negotiationMethods = Arrays.stream(allMethods) + .filter(method -> method.getName().startsWith("generateNegotiation") + || method.getName().startsWith("getNegotiation") + || "validateProposePromptAndDataFilling".equals(method.getName()) + || "validateAcceptPromptAndDataFilling".equals(method.getName()) + || "validateRejectPromptAndDataFilling".equals(method.getName()) + || "validateAbortPromptAndDataFilling".equals(method.getName())) + .toList(); + Set names = negotiationMethods.stream().map(Method::getName).collect(Collectors.toSet()); + assertEquals( + EXPECTED_NEGOTIATION_METHOD_PARAMETERS.keySet(), + names, + facade.getSimpleName() + " must expose exactly the twelve v3 negotiation methods"); + assertEquals( + EXPECTED_NEGOTIATION_METHOD_PARAMETERS.size(), + negotiationMethods.size(), + facade.getSimpleName() + " must not overload any negotiation method"); + for (Method method : negotiationMethods) { + assertEquals( + EXPECTED_NEGOTIATION_METHOD_PARAMETERS.get(method.getName()), + method.getParameterCount(), + "parameter count of " + facade.getSimpleName() + "." + method.getName()); + } + } + + private static void assertNoV2ShapeNames(Class facade) { + for (Method method : facade.getMethods()) { + String name = method.getName(); + for (String fragment : REMOVED_V2_METHOD_NAME_FRAGMENTS) { + assertFalse( + name.toLowerCase(Locale.ROOT).contains(fragment.toLowerCase(Locale.ROOT)), + facade.getSimpleName() + " must not expose the removed v2-shape method name fragment " + + fragment + " but declares " + name); + } + } + } + + private static List componentNames(Class recordType) { + return Arrays.stream(recordType.getRecordComponents()) + .map(RecordComponent::getName) + .collect(Collectors.toList()); + } +} 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..dbd7de18 --- /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": {"text": "test"}, "expected": {"client": {"promptText": "## rendered prompt"}, "server": {"outcome": "success"}}}]} 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 diff --git a/a2a-t-sample/src/test/resources/sample/authz-policy/test/validate-schema-empty.json b/a2a-t-sample/src/test/resources/sample/authz-policy/test/validate-schema-empty.json new file mode 100644 index 00000000..71f99ae0 --- /dev/null +++ b/a2a-t-sample/src/test/resources/sample/authz-policy/test/validate-schema-empty.json @@ -0,0 +1 @@ +{"scenarios": [{"label": "vs-empty", "entry": "from_text", "input": {"text": "新增一条授权策略"}, "expected": {"client": {"promptText": "## rendered prompt"}, "server": {"outcome": "success"}}, "validate_schema": {}}]} \ No newline at end of file diff --git a/a2a-t-sample/src/test/resources/sample/authz-policy/test/validate-schema-scenarios.json b/a2a-t-sample/src/test/resources/sample/authz-policy/test/validate-schema-scenarios.json new file mode 100644 index 00000000..679d7ecb --- /dev/null +++ b/a2a-t-sample/src/test/resources/sample/authz-policy/test/validate-schema-scenarios.json @@ -0,0 +1 @@ +{"scenarios": [{"label": "vs-present", "entry": "from_text", "input": {"text": "新增一条授权策略"}, "expected": {"client": {"promptText": "## rendered prompt"}, "server": {"outcome": "success"}}, "validate_schema": {"动网操作的授权策略列表": {"处置类型": "应为简短的处置动作短语"}}}, {"label": "vs-missing", "entry": "from_text", "input": {"text": "新增一条授权策略"}, "expected": {"client": {"promptText": "## rendered prompt"}, "server": {"outcome": "success"}}}]} \ 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-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-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已被占用,无法执行隧道调优(可选)" + } +] 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 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..5da2cc58 --- /dev/null +++ b/a2a-t-sample/task-t-test-cases.md @@ -0,0 +1,95 @@ +# 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` 分组) +- 命中规则(分字段两档):结构化字段(`accessPort`/`bizScenario`/`faultTime`/`eventSerialNo`)去除空白并统一小写后 + **完全相等**即为命中,被截断的前缀(如仅提取 `P781`)视为未命中;详情字段(`faultDetail`)归一化后**相同或互相包含** + 即命中(自由文本天然比期望关键词长);提取值缺失视为未命中 +- 执行方式:`TaskTDemoMain [env-file] [case]` —— `case` 可选 `text`/`data`/`rejection`/`all`,不传则执行整组用例。 + +## 客户端 / 服务端字段对照 + +两端 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-KXVN-PTN7900-23-TPA1EG24-17 | 专线质差 | 2026-05-11 | event-id-20260511-09013 | 320ms | +| TC-02 | text-logical-port-interruption | P781-QMBR-PTN7900-5-TPA1EG24-09(cvlan=100) | 专线中断 | 2026-05-16 | fault-id-1-017-20260516-11234 | 完全中断 | +| TC-03 | text-port-quality-jitter | P781-PTZS-PTN7900-7-TPA1EG24-21 | 专线质差 | 2026-05-22 | event-id-20260522-00812 | 丢包 | +| TC-04 | text-port-interruption-route | P781-FDWC-PTN7900-9-TPA1EG24-31 | 专线中断 | 2026-06-03 | event-id-20260603-02341 | 彻底不通 | +| TC-05 | text-port-quality-latency | P781-LJVK-PTN7900-4-TPA1EG24-11 | 专线质差 | 2026-06-08 | event-id-20260608-01102 | 时延 | +| TC-06 | text-logical-port-interruption-vlan | P781-NRQF-PTN7900-6-TPA1EG24-27(cvlan=200) | 专线中断 | 2026-06-12 | fault-id-9-023-20260612-04578 | 连不上 | +| TC-07 | text-optional-slots-missing | P781-BZHK-PTN7900-3-TPA1EG24-19 | 专线质差 | — | — | 卡顿 | + +## 用例二:generateTaskPromptFromDataWithSchema(结构化英文业务字段 + 语义 schema) + +| # | 样例名 | 端口 | 分类 | 时间(ISO-8601) | 流水号 | 详情期望关键词 | +|---|---|---|---|---|---|---| +| TC-08 | data-port-quality | P781-YSXR-PTN7900-2-TPA1EG24-03 | 专线质差 | 2026-05-11T08:21:46Z | event-id-20260511-09013 | 320ms | +| TC-09 | data-logical-port-interruption | P781-QMBR-PTN7900-5-TPA1EG24-09(cvlan=100) | 专线中断 | 2026-05-16T12:10:00Z | fault-id-1-017-20260516-11234 | 完全中断 | +| TC-10 | data-port-jitter | P781-PTZS-PTN7900-7-TPA1EG24-21 | 专线质差 | 2026-05-22T14:05:00Z | event-id-20260522-00812 | 时延抖动 | +| TC-11 | data-logical-port-interruption-route | P781-GHMT-PTN7900-1-TPA1EG24-08(cvlan=300) | 专线中断 | 2026-06-03T09:40:00Z | event-id-20260603-02341 | 完全中断 | +| TC-12 | data-port-quality-loss | P781-CWDV-PTN7900-6-TPA1EG24-13 | 专线质差 | 2026-06-08T15:20:00Z | event-id-20260608-01102 | 丢包 | +| TC-13 | data-port-quality-core-slow | P781-TJPN-PTN7900-8-TPA1EG24-15 | 专线质差 | 2026-06-18T10:05:00Z | event-id-20260618-03342 | 时延 | +| TC-14 | data-optional-slots-missing | P781-RMQF-PTN7900-7-TPA1EG24-18 | 专线中断 | — | — | 完全中断 | + +> 说明: +> 1. 用例一(text)的时间期望取日期前缀(如 `2026-05-11`)——口语文本不含 ISO 字面量,日期是稳定事实; +> 用例二(data)的时间期望取完整 ISO-8601 时间戳。 +> 2. 期望值均按**服务端 key** 记分(`accessPort`/`bizScenario`/`faultTime`/`eventSerialNo`/`faultDetail`), +> 与 `validateTaskPromptAndDataFilling` 实际输出一致。 +> 3. 详情字段命中规则较宽松(等价或互相包含);结构化字段需归一化后**完全相等**,被截断的前缀不命中。 +> 4. TC-07/TC-14 为**可选槽位观测用例**:输入故意缺时间与流水号(二者均为可选槽位),期望校验**通过**且其余 +> 字段命中——验证"可选槽位缺失不纳入拒绝集"这一校验口径;其期望值不含该两项,不进入字段准确率分母。 + +## 用例三:缺少关键槽位拒绝用例(期望 rejected) + +正向样本评测的是提取准确率;本组用例验证语义校验的**兜底能力**——输入内容故意缺失**必填槽位**(接入端口、 +投诉分类)或携带**枚举外取值**,服务端应抛出 `negotiation.semantic_rejected`。与用例一/二对应,本组同时覆盖 +**文本变体**(`generateTaskPromptFromText`)与**数据变体**(`generateTaskPromptFromDataWithSchema`)。 +被拦截样本数占样本总数的比例即**拦截率**(服务端语义拒绝 + 客户端生成阶段拦截均计为拦截);意外通过时打印 +实际提取参数以便排查。本组用例独立于正向样本计分,不进入字段准确率统计。 + +> 拒绝集口径:仅以**契约级硬项**为拒绝依据——违反任一 schema 的 `required`(服务端 `accessPort`/`bizScenario`, +> 客户端 `portName`/`complaintScenario`)或取值不在**契约枚举**(两端 `complainScenario`/`bizScenario` 的 `enum`)。 +> 时间、流水号均为**可选槽位**,不在拒绝集内;若需观察可选槽位缺失的行为,应作为正向观察用例而非拒绝断言, +> 避免依赖未契约化的语义规则造成拦截率波动。 +> +> 拦截结果按**双口径**报告:**服务端校验点拒绝率**(仅统计 `negotiation.semantic_rejected`,衡量服务端校验点本身的 +> 拦截能力)与**总拦截率**(服务端拒绝 + 客户端生成阶段拦截,衡量整条链路的兜底能力)——客户端拦截发生在模板 +> 槽位填充阶段,与服务端语义校验是两套判断,分开统计才能定位到具体层。 + +### 文本变体(generateTaskPromptFromText) + +| # | 样例名 | 缺失槽位 | 保留内容 | +|---|---|---|---| +| TC-15 | text-missing-access-port | 接入端口 | 分类、时间、详情、流水号 | +| TC-16 | text-missing-scenario | 投诉分类 | 端口、时间、流水号 | +| TC-17 | text-minimal-no-key-fields | 全部关键槽位 | 仅一句"专线业务好像出问题了" | +| 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` 取值"专线掉线"(不在契约 `enum`) | 端口、时间、流水号、详情 | +| TC-22 | data-missing-port-and-scenario | 缺 `portName` + `complaintScenario` | 时间、流水号、详情 | \ No newline at end of file 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 ae48d1a4..d6f77908 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -1,50 +1,152 @@ package net.openan.a2at.sdk.server; import java.nio.file.Path; +import java.util.List; import java.util.Map; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; -import net.openan.a2at.sdk.server.assembly.DefaultA2ATServerBuilder; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; import net.openan.a2at.sdk.core.model.A2ATConfig; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; +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; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** - * High-level server facade for prompt compliance and negotiation APIs. - * The caller provides the `.env` file path explicitly, typically after copying the repository `env.example`. + * 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 static final Logger LOGGER = LoggerFactory.getLogger(A2ATServer.class); + 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; + + private final TelemetryHook hook; + /** - * Creates a server facade from one user-supplied `.env` path. + * Creates a server facade from one user-supplied `.env` path without telemetry observation. * * @param envPath user-supplied `.env` file path */ public A2ATServer(Path envPath) { + this(envPath, null); + } + + /** + * Creates a server facade from one user-supplied `.env` path with an optional telemetry hook observing every + * facade operation. + * + * @param envPath user-supplied `.env` file path + * @param hook telemetry hook receiving the SDK operation events; {@code null} disables observation + */ + public A2ATServer(Path envPath, @Nullable TelemetryHook hook) { Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); - A2ATConfig config = resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + A2ATConfig config = + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder().envPath(resolvedEnvPath).config(config); + this.hook = hook == null ? TelemetryHook.NO_OP : hook; this.promptComplianceOrchestrator = builder.buildPromptComplianceOrchestrator(); this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); + this.negotiationContentService = + new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.templateQueryService = builder.buildTemplateQueryService(); + this.taskContentValidator = builder.buildTaskContentValidator(); + this.notificationContentValidator = builder.buildNotificationContentValidator(); + this.authContentValidator = builder.buildAuthContentValidator(); + } + + private T observed(String method, Supplier action) { + TelemetryOperation started; + try { + started = hook.startSdkOperation("server", method); + } catch (RuntimeException error) { + LOGGER.warn("a2at: startSdkOperation failed; telemetry disabled for this call", error); + started = TelemetryOperation.NO_OP; + } + try (TelemetryOperation operation = started) { + try { + T result = action.get(); + try { + operation.markSuccess(); + } catch (RuntimeException callbackError) { + LOGGER.warn("a2at: telemetry hook callback failed", callbackError); + } + return result; + } catch (RuntimeException error) { + try { + operation.markError(error); + } catch (RuntimeException callbackError) { + LOGGER.warn("a2at: telemetry hook callback failed", callbackError); + } + throw error; + } + } + } + + static @Nullable String terminalOutcome(NegotiationStatus status) { + if (status == NegotiationStatus.AGREED) { + return "accept"; + } + return status == NegotiationStatus.REJECTED ? "reject" : null; + } + + private void reportNegotiationRounds(NegotiationContext context, NegotiationStatus status) { + try { + String outcome = terminalOutcome(status); + if (outcome != null) { + hook.reportNegotiationRounds(context.negotiationId(), outcome, context.round()); + } + } catch (RuntimeException error) { + LOGGER.warn("a2at: negotiation metric report failed", error); + } } /** * Checks one processed task prompt for server-side compliance. * + *

      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(String processedPromptText) { - return promptComplianceOrchestrator.checkTaskPrompt(processedPromptText); + public PromptComplianceResult checkTaskPrompt(@NonNull String processedPromptText) { + return observed("checkTaskPrompt", () -> promptComplianceOrchestrator.checkTaskPrompt(processedPromptText)); } /** @@ -55,8 +157,9 @@ 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) { - return negotiationOrchestrator.startNegotiation(type, contentText, facts); + public Map startNegotiation( + @NonNull NegotiationType type, @NonNull String contentText, @NonNull Map facts) { + return observed("startNegotiation", () -> negotiationOrchestrator.startNegotiation(type, contentText, facts)); } /** @@ -66,8 +169,8 @@ 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) { - return negotiationOrchestrator.receiveNegotiation(message, context); + public Map receiveNegotiation(@NonNull String message, @NonNull Map context) { + return observed("receiveNegotiation", () -> negotiationOrchestrator.receiveNegotiation(message, context)); } /** @@ -79,21 +182,513 @@ public Map receiveNegotiation(String message, Map continueNegotiation( - NegotiationContext context, NegotiationStatus status, String contentText) { - return negotiationOrchestrator.continueNegotiation(context, status, contentText); - } - - private A2ATConfig resolvePromptResourceLocalRootDir(A2ATConfig config, Path envPath) { - String localRootDir = config.prompt().localRootDir(); - Path localRootPath = Path.of(localRootDir); - Path resolvedLocalRootPath = localRootPath.isAbsolute() - ? localRootPath.normalize() - : envPath.getParent().resolve(localRootPath).toAbsolutePath().normalize(); - return new A2ATConfig( - new net.openan.a2at.sdk.core.model.PromptRuntimeConfig( - config.prompt().language(), config.prompt().sourceType(), resolvedLocalRootPath.toString()), - config.llm(), - config.negotiation(), - config.promptCompliance()); + @NonNull NegotiationContext context, @NonNull NegotiationStatus status, @NonNull String contentText) { + return observed("continueNegotiation", () -> { + Map result = negotiationOrchestrator.continueNegotiation(context, status, contentText); + reportNegotiationRounds(context, status); + return result; + }); + } + + /** + * Generates a propose-phase negotiation message from typed data. + * + *

      This variant is deterministic and never calls an LLM: the typed content is validated, dispatched to the + * generator of the negotiation type addressed by the template URI and rendered from that template. + * + * @param data typed propose input carrying the negotiation context and the typed content + * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/propose/v1}; + * its performative segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the data, its context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase or type contradicts the + * method or the content type + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template exists for the URI in any resource root, the code + * {@code negotiation.content_invalid} when a required content field is blank or empty, the code + * {@code negotiation.invalid_input} when the content combines a non-blank confirm request with conditional + * sections, or the code {@code template.render_failed} when rendering the template fails + */ + public MetadataContent generateNegotiationProposePromptFromData( + @NonNull NegotiationProposeData data, @NonNull String templateUri) { + return observed( + "generateNegotiationProposePromptFromData", + () -> negotiationContentService.generateProposeFromData(data, parseTemplateUri(templateUri))); + } + + /** + * Generates an accept-phase negotiation message from typed data. + * + *

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

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

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

      This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extraction step + * is retried up to the configured attempt limit on the retryable failure codes + * {@code negotiation.content_extract_failed} and {@code llm.invocation_failed}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri raw template URI string such as {@code Negotiation-T/target-negotiation/propose/v1}; its + * performative segment must be {@code propose} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank or the + * extracted content contradicts the phase, or {@code input.text_too_long} when the text exceeds the configured + * maximum length (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public MetadataContent generateNegotiationProposePromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + return observed( + "generateNegotiationProposePromptFromText", + () -> negotiationContentService.generateProposeFromText(text, context, parseTemplateUri(templateUri))); + } + + /** + * Generates an accept-phase negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Accept}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri raw template URI string such as {@code Negotiation-T/information-negotiation/accept-reject/v1}; + * its performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Accept}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public MetadataContent generateNegotiationAcceptPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + return observed( + "generateNegotiationAcceptPromptFromText", + () -> negotiationContentService.generateAcceptFromText(text, context, parseTemplateUri(templateUri))); + } + + /** + * Generates a reject-phase negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the template URI and then renders + * deterministically like the from-data variant. The template is loaded before the LLM call and the extracted + * conclusion must be {@code Reject}. + * + * @param text free-text input describing the message content + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri raw template URI string such as {@code Negotiation-T/feasibility-negotiation/accept-reject/v1}; + * its performative segment must be {@code accept-reject} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses a required field, {@code negotiation.invalid_input} when the text is blank, + * {@code negotiation.conclusion_mismatch} when the extracted conclusion is not {@code Reject}, or + * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public MetadataContent generateNegotiationRejectPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + return observed( + "generateNegotiationRejectPromptFromText", + () -> negotiationContentService.generateRejectFromText(text, context, parseTemplateUri(templateUri))); + } + + /** + * Generates an abort negotiation message from free text. + * + *

      This variant runs one LLM content-extraction step constrained by the common abort template and then renders + * deterministically like the from-data variant. + * + * @param text free-text input stating the termination reason + * @param context negotiation context carried in the {@code negotiationContext} metadata entry of the generated + * message without any LLM involvement + * @param templateUri raw template URI string of the common abort template {@code Negotiation-T/common/abort/v1} + * @return generated message carrying the template URI, the rendered message text and the negotiation extension URI + * @throws NullPointerException if the context or the template URI is null + * @throws IllegalArgumentException if the template URI is blank, malformed or does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationGenerationException with the code + * {@code template.not_found} when no template or prompt resource exists for the URI and language, + * {@code negotiation.content_extract_failed}, {@code llm.invocation_failed} or {@code llm.response_invalid} + * when the extraction step fails after exhausting its retries, {@code negotiation.field_missing} when the + * extracted content misses the termination reason, {@code negotiation.invalid_input} when the text is blank, or + * {@code input.text_too_long} when the text exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public MetadataContent generateNegotiationAbortPromptFromText( + @NonNull String text, + net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, + @NonNull String templateUri) { + return observed( + "generateNegotiationAbortPromptFromText", + () -> negotiationContentService.generateAbortFromText(text, context, parseTemplateUri(templateUri))); + } + + /** + * Lists every template available for the configured language across all A2A-T extensions. + * + *

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

      This query never throws for a well-formed template URI: a template that exists nowhere for the configured + * language returns an empty result and logs an actionable warning. + * + * @param templateUri raw template URI string such as {@code Negotiation-T/target-negotiation/propose/v1} or + * {@code Task-T/network-layer/ran-energy-saving/v1} + * @return the addressed template, or an empty result when the template does not exist for the configured language + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + */ + public Optional getPrompt(@NonNull String templateUri) { + return observed("getPrompt", () -> templateQueryService.getPrompt(parseTemplateUri(templateUri))); + } + + /** + * Validates a propose-phase negotiation message and extracts its parameters. + * + *

      The pipeline checks the template URI format before any LLM call, runs the deterministic rule gate, then + * performs one semantic validation LLM call (retried on the retryable failure codes) and merges the extracted + * parameters with the rule-level context parameters; context parameters win on conflict. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string declaring the expected negotiation type and phase; its performative segment + * must be {@code propose} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateProposePromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + return observed( + "validateProposePromptAndDataFilling", + () -> negotiationContentService.validateProposePromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri))); + } + + /** + * Validates an accept-phase negotiation message and extracts its parameters. + * + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * accept: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * accept-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string declaring the expected negotiation type and phase; its performative segment + * must be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateAcceptPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + return observed( + "validateAcceptPromptAndDataFilling", + () -> negotiationContentService.validateAcceptPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri))); + } + + /** + * Validates a reject-phase negotiation message and extracts its parameters. + * + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * reject: the template URI must declare the {@code accept-reject} segment and the message must satisfy the + * reject-phase semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string declaring the expected negotiation type and phase; its performative segment + * must be {@code accept-reject} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed, or its phase contradicts the method + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateRejectPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + return observed( + "validateRejectPromptAndDataFilling", + () -> negotiationContentService.validateRejectPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri))); + } + + /** + * Validates an abort negotiation message and extracts its parameters. + * + *

      The pipeline is the one of {@link #validateProposePromptAndDataFilling} with the expected phase fixed to + * abort: the template URI must address the common abort template and the message must satisfy the abort-phase + * semantic constraints. + * + * @param prompt rendered negotiation message text to validate + * @param context negotiation context carried alongside the message in the A2A-T metadata; {@code null} is reported + * as not being a negotiation message + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string of the common abort template {@code Negotiation-T/common/abort/v1} + * @return filled parameter data carrying the context parameters and the extracted parameters + * @throws NullPointerException if the schema or the template URI is null + * @throws IllegalArgumentException if the template URI is blank, malformed or does not address the common abort + * template + * @throws net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank or is not a negotiation message, + * {@code negotiation.rule_violation} when the negotiation context violates a rule (nested slot error codes + * such as {@code negotiation.invalid_context_id} or {@code negotiation.round_exceeded}), one of the closed + * {@code negotiation.*} code set (for example + * {@code negotiation.conclusion_content_mismatch}) when a rule or semantic check rejects the message, + * {@code negotiation.semantic_rejected} when the semantic validation rejects the message, + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, or {@code template.not_found} when the semantic validation prompt resources are missing, or + * {@code input.text_too_long} when the prompt exceeds the configured maximum length (A2AT_INPUT_TEXT_MAX_CHARS, + * default 16384) + */ + public FilledParamData validateAbortPromptAndDataFilling( + @NonNull String prompt, + net.openan.a2at.sdk.core.model.NegotiationContext context, + @NonNull Map schema, + @NonNull String templateUri) { + return observed( + "validateAbortPromptAndDataFilling", + () -> negotiationContentService.validateAbortPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri))); + } + + /** + * Validates a task prompt and extracts its parameters. + * + * @param prompt rendered task prompt text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string declaring the expected task template; its prefix segment must be + * {@code Task-T} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank, the schema is null, or the template URI + * addresses another extension or carries an unsupported version, the code + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, the code + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, the code {@code template.not_found} when the template or the validation prompt resources are + * missing, or the code {@code input.text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public FilledParamData validateTaskPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { + return observed( + "validateTaskPromptAndDataFilling", + () -> taskContentValidator.validate(prompt, schema, parseTemplateUri(templateUri))); + } + + /** + * Validates a notification prompt and extracts its parameters. + * + * @param prompt rendered notification prompt text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string declaring the expected notification template; its prefix segment must + * be {@code Notification-T} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank, the schema is null, or the template URI + * addresses another extension or carries an unsupported version, the code + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, the code + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, the code {@code template.not_found} when the template or the validation prompt resources are + * missing, or the code {@code input.text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public FilledParamData validateNotificationPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { + return observed( + "validateNotificationPromptAndDataFilling", + () -> notificationContentValidator.validate(prompt, schema, parseTemplateUri(templateUri))); + } + + /** + * Validates an authorization prompt and extracts its parameters. + * + * @param prompt rendered authorization prompt text to validate + * @param schema caller-provided parameter JSON schema describing the parameters to extract + * @param templateUri raw template URI string declaring the expected authorization template; its prefix segment + * must be {@code Authorization-T} + * @return filled parameter data carrying the merged parameters + * @throws NullPointerException if the template URI is null + * @throws IllegalArgumentException if the template URI is blank or malformed + * @throws net.openan.a2at.sdk.core.validation.ContentValidationException with the code + * {@code negotiation.invalid_input} when the prompt is null or blank, the schema is null, or the template URI + * addresses another extension or carries an unsupported version, the code + * {@code negotiation.semantic_rejected} when the semantic validation rejects the content, the code + * {@code llm.invocation_failed} or {@code llm.response_invalid} when the semantic step fails after exhausting + * its retries, the code {@code template.not_found} when the template or the validation prompt resources are + * missing, or the code {@code input.text_too_long} when the prompt exceeds the configured maximum length + * (A2AT_INPUT_TEXT_MAX_CHARS, default 16384) + */ + public FilledParamData validateAuthPromptAndDataFilling( + @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { + return observed( + "validateAuthPromptAndDataFilling", + () -> authContentValidator.validate(prompt, schema, parseTemplateUri(templateUri))); + } + + private static TemplateUri parseTemplateUri(String templateUri) { + Objects.requireNonNull(templateUri, "templateUri"); + return TemplateUri.parse(templateUri) + .orElseThrow(() -> new IllegalArgumentException("Unparseable template URI: " + templateUri)); } } diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ClasspathServerPromptTemplateLoader.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ClasspathServerPromptTemplateLoader.java deleted file mode 100644 index 91370ec0..00000000 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ClasspathServerPromptTemplateLoader.java +++ /dev/null @@ -1,46 +0,0 @@ -package net.openan.a2at.sdk.server.assembly; - -import java.util.List; -import net.openan.a2at.sdk.prompt.resources.loader.ClasspathPromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.ClasspathPromptTemplateLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.resources.ClasspathPromptResourceLoader; -import net.openan.a2at.sdk.server.model.PromptTemplateDefinition; -import net.openan.a2at.sdk.server.model.PromptTemplateSlotDefinition; - -/** - * Loads server-side prompt template definitions from packaged classpath prompt resources. - * - * @since 2026-06 - */ -public final class ClasspathServerPromptTemplateLoader { - - private final PromptTemplateTextLoader templateLoader; - - private final PromptSlotSchemaLoader slotSchemaLoader; - - public ClasspathServerPromptTemplateLoader(ClasspathPromptResourceLoader resourceLoader) { - this.templateLoader = new ClasspathPromptTemplateLoader(resourceLoader); - this.slotSchemaLoader = new ClasspathPromptSlotSchemaLoader(resourceLoader); - } - - public PromptTemplateDefinition load(String scenarioCode, String language) { - String templateText = templateLoader.loadTemplate(scenarioCode, language); - PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(scenarioCode, language); - return new PromptTemplateDefinition( - scenarioCode, language, templateText, toSlotDefinitions(slotSchema)); - } - - public List loadAll(List scenarios, String language) { - return scenarios.stream().map(scenario -> load(scenario.scenarioCode(), language)).toList(); - } - - private static List toSlotDefinitions(PromptSlotSchema slotSchema) { - return slotSchema.slotDefinitions().stream() - .map(def -> new PromptTemplateSlotDefinition(def.name(), def.required())) - .toList(); - } -} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java index 510ea887..b3239996 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilder.java @@ -3,23 +3,27 @@ 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.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; -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.server.metadata.TemplateMatchingPromptMetadataExtractor; -import net.openan.a2at.sdk.server.validation.TemplateRoundTripPromptSemanticValidator; 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; import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptResourceAccess; import net.openan.a2at.sdk.prompt.resources.model.ScenarioDefinition; -import net.openan.a2at.sdk.server.model.PromptTemplateDefinition; +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. @@ -28,10 +32,6 @@ */ public final class DefaultA2ATServerBuilder { - private static final String LOCAL_RULE_PROVIDER = "local_rule"; - - private static final String OPENAI_COMPATIBLE_PROVIDER = "openai_compatible"; - private static final String SCENARIO_RECOGNITION_PROMPT = "scenario_recognition"; private static final String SLOT_EXTRACTION_PROMPT = "slot_extraction"; @@ -42,6 +42,12 @@ public final class DefaultA2ATServerBuilder { private Path envPath; + private LLMClient llmClient; + + private PromptResourceAccess promptResourceAccess; + + private DefaultServerPromptComplianceOrchestrator promptComplianceOrchestrator; + /** * Creates one new builder instance. * @@ -73,47 +79,41 @@ 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. * * @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(); - - PromptResourceAccess resources = PromptResourceAccess.create(config.prompt()); - if (LOCAL_RULE_PROVIDER.equals(config.llm().provider())) { - return buildLocalRulePromptComplianceOrchestrator(resources); + if (promptComplianceOrchestrator != null) { + return promptComplianceOrchestrator; } - return buildLlmBackedPromptComplianceOrchestrator(resources); - } - - private DefaultServerPromptComplianceOrchestrator buildLocalRulePromptComplianceOrchestrator( - PromptResourceAccess resources) { - String language = config.prompt().language(); - List templates; - if (resources.classpath()) { - List scenarios = resources.loadScenarios(language); - templates = new ClasspathServerPromptTemplateLoader(resources.classpathResourceLoader()) - .loadAll(scenarios, language); - } else { - templates = new LocalFileServerPromptTemplateLoader(resources.localRootDir()).loadAll(language); - } - return new DefaultServerPromptComplianceOrchestrator( - new TemplateMatchingPromptMetadataExtractor(templates), - new TemplateRoundTripPromptSemanticValidator()); - } - - private DefaultServerPromptComplianceOrchestrator buildLlmBackedPromptComplianceOrchestrator( - PromptResourceAccess resources) { + PromptResourceAccess resources = promptResourceAccess(); 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"); @@ -122,9 +122,9 @@ private DefaultServerPromptComplianceOrchestrator buildLlmBackedPromptCompliance 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 LlmScenarioRecognizer(client), scenarios, language, scenarioSystemPrompt, @@ -132,9 +132,12 @@ private DefaultServerPromptComplianceOrchestrator buildLlmBackedPromptCompliance templateLoader, slotSchemaLoader, new DefaultStructuredPromptSlotValueExtractor( - llmClient, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)), + client, slotSchemaLoader, slotSystemPrompt, slotUserPrompt)), new LlmBackedPromptSemanticValidator( - llmClient, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt)); + client, slotSchemaLoader, semanticSystemPrompt, semanticUserPrompt), + config.inputLimits().maxTextChars(), + language); + return promptComplianceOrchestrator; } /** @@ -150,6 +153,90 @@ public RoleBoundNegotiationOrchestrator buildNegotiationOrchestrator() { .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); @@ -158,23 +245,32 @@ 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 (!LOCAL_RULE_PROVIDER.equals(config.llm().provider()) - && !OPENAI_COMPATIBLE_PROVIDER.equals(config.llm().provider())) { - throw new UnsupportedOperationException("Unsupported LLM provider: " + config.llm().provider()); + 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()); + throw new UnsupportedOperationException("Unsupported negotiation state store type: " + + config.negotiation().stateStoreType()); } } - private LLMClient createLlmClient() { - LLMClientConfig loadedConfig = LLMConfigLoader.load(envPath); - return LLMClientFactory.create(loadedConfig.provider(), loadedConfig); + private synchronized LLMClient llmClient() { + if (llmClient == null) { + llmClient = LLMClientFactory.create(config.llm().provider(), LLMClientConfig.from(config.llm())); + } + return llmClient; } + private synchronized PromptResourceAccess promptResourceAccess() { + if (promptResourceAccess == null) { + promptResourceAccess = PromptResourceAccess.create(config.prompt()); + } + return promptResourceAccess; + } } diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoader.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoader.java deleted file mode 100644 index a61bb715..00000000 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoader.java +++ /dev/null @@ -1,83 +0,0 @@ -package net.openan.a2at.sdk.server.assembly; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import net.openan.a2at.sdk.core.exception.ResourceNotFoundException; -import net.openan.a2at.sdk.core.exception.SdkException; -import net.openan.a2at.sdk.prompt.resources.loader.LocalFilePromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.LocalFilePromptTemplateLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptSlotSchemaLoader; -import net.openan.a2at.sdk.prompt.resources.loader.PromptTemplateTextLoader; -import net.openan.a2at.sdk.prompt.resources.model.PromptSlotSchema; -import net.openan.a2at.sdk.server.model.PromptTemplateDefinition; -import net.openan.a2at.sdk.server.model.PromptTemplateSlotDefinition; - -/** - * Loads server-side prompt template definitions from one local prompt resource root. - * - * @since 2026-06 - */ -public final class LocalFileServerPromptTemplateLoader { - - private final PromptTemplateTextLoader templateLoader; - - private final PromptSlotSchemaLoader slotSchemaLoader; - - private final Path promptRootDir; - - /** - * Creates one local template loader. - * - * @param promptRootDir prompt resource root directory - */ - public LocalFileServerPromptTemplateLoader(Path promptRootDir) { - this.promptRootDir = promptRootDir; - this.templateLoader = new LocalFilePromptTemplateLoader(promptRootDir); - this.slotSchemaLoader = new LocalFilePromptSlotSchemaLoader(promptRootDir); - } - - /** - * Loads one prompt template definition for one scenario and language. - * - * @param scenarioCode scenario code - * @param language language - * @return prompt template definition - */ - public PromptTemplateDefinition load(String scenarioCode, String language) { - String templateText = templateLoader.loadTemplate(scenarioCode, language); - PromptSlotSchema slotSchema = slotSchemaLoader.loadSlotSchema(scenarioCode, language); - return new PromptTemplateDefinition( - scenarioCode, language, templateText, toSlotDefinitions(slotSchema)); - } - - /** - * Loads all prompt template definitions available for one language. - * - * @param language language - * @return all discovered prompt template definitions - */ - public List loadAll(String language) { - Path templatesRoot = promptRootDir.resolve("templates"); - if (!Files.exists(templatesRoot)) { - throw new ResourceNotFoundException("Prompt resource file does not exist.", templatesRoot.toString()); - } - - try (var paths = Files.list(templatesRoot)) { - return paths - .filter(Files::isDirectory) - .map(path -> path.getFileName().toString()) - .map(scenarioCode -> load(scenarioCode, language)) - .toList(); - } catch (IOException exception) { - throw new SdkException("Failed to scan prompt template resources: " + templatesRoot, exception); - } - } - - private static List toSlotDefinitions(PromptSlotSchema slotSchema) { - return slotSchema.slotDefinitions().stream() - .map(def -> new PromptTemplateSlotDefinition(def.name(), def.required())) - .toList(); - } -} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilder.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilder.java index d2c0eb8e..3b3d02f2 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilder.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilder.java @@ -4,10 +4,9 @@ import net.openan.a2at.sdk.negotiation.runtime.RoleBoundNegotiationOrchestrator; import net.openan.a2at.sdk.negotiation.store.impl.InMemoryNegotiationStore; import net.openan.a2at.sdk.negotiation.store.NegotiationStore; -import net.openan.a2at.sdk.negotiation.handler.ClarificationNegotiation; import net.openan.a2at.sdk.negotiation.handler.FeasibilityNegotiation; -import net.openan.a2at.sdk.negotiation.handler.FulfillmentNegotiation; import net.openan.a2at.sdk.negotiation.handler.InformationNegotiation; +import net.openan.a2at.sdk.negotiation.handler.TargetNegotiation; import net.openan.a2at.sdk.negotiation.types.model.NegotiationRole; import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; import net.openan.a2at.sdk.negotiation.types.model.TaskPromptComplianceFailure; @@ -68,9 +67,8 @@ public RoleBoundNegotiationOrchestrator build() { : TaskPromptComplianceResult.failure(new TaskPromptComplianceFailure( result.failure().code(), result.failure().message())); })) - .register(NegotiationType.CLARIFICATION, new ClarificationNegotiation()) + .register(NegotiationType.TARGET, new TargetNegotiation()) .register(NegotiationType.FEASIBILITY, new FeasibilityNegotiation()) - .register(NegotiationType.FULFILLMENT, new FulfillmentNegotiation()) .build(); return new RoleBoundNegotiationOrchestrator(handler, NegotiationRole.SERVER); } diff --git a/a2a-t-server/src/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..6dac1f0c 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestrator.java @@ -1,11 +1,16 @@ 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.exception.PromptComplianceCheckException; import net.openan.a2at.sdk.server.validation.ServerPromptSemanticValidator; +import org.jspecify.annotations.Nullable; /** * Minimal runnable server-side prompt compliance orchestrator. @@ -14,10 +19,17 @@ */ 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. * @@ -26,19 +38,63 @@ 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, 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.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..c281ad26 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/exception/PromptComplianceCheckException.java @@ -1,13 +1,22 @@ 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; + /** - * 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 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 RuntimeException { - - private final String code; +public final class PromptComplianceCheckException extends A2ATBusinessException { private final String stage; @@ -19,18 +28,22 @@ 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. + * Creates a standardized compliance-check exception whose message is rendered from the code's template. * - * @return stable error code + * @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 String code() { - return code; + public PromptComplianceCheckException( + ErrorCatalog entry, @Nullable String language, @Nullable Map facts, String stage) { + super(entry, ErrorMessages.render(entry, language, facts), facts); + this.stage = stage; } /** @@ -38,7 +51,7 @@ public String code() { * * @return failure stage */ - public String 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 993c31ff..19c185b2 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractor.java @@ -2,7 +2,9 @@ import java.util.List; import java.util.Map; -import java.util.stream.Collectors; +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; @@ -17,15 +19,22 @@ 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. + * 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; @@ -77,92 +86,124 @@ 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( - "processed_prompt_parse_error", - result.errorMessage() == null ? "Scenario recognition failed." : result.errorMessage(), - "prompt_parse"); + if (!result.matched() + || result.scenarioCode() == null + || result.scenarioCode().isBlank()) { + throw scenarioNotMatched(result.errorMessage()); } return result; } catch (ScenarioRecognitionException error) { - throw new PromptComplianceCheckException("processed_prompt_parse_error", error.getMessage(), "prompt_parse"); + 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("template_not_found", error.getMessage(), "generation"); + 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 static void validateExtractionResult( + private void validateExtractionResult( StructuredSlotExtractionResult extractionResult, PromptSlotSchema slotSchema) { if (!extractionResult.slotErrors().isEmpty()) { - throw new PromptComplianceCheckException( - "slot_validation_error", - extractionResult.slotErrors().stream() - .map(StructuredSlotValidationError::message) - .filter(message -> message != null && !message.isBlank()) - .collect(Collectors.joining("; ")), - "slot_validation"); + 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( - "slot_validation_error", - "Required slot '" + definition.name() + "' is missing.", + 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(value.trim())) { - throw new PromptComplianceCheckException( - "slot_validation_error", - "Slot '" + definition.name() + "' violates allowed values.", - "slot_validation"); + && !definition.allowedValues().contains(trimmedValue)) { + throw constraintViolation(definition, trimmedValue); } if (definition.pattern() != null && !definition.pattern().isBlank() - && !value.trim().matches(definition.pattern())) { - throw new PromptComplianceCheckException( - "slot_validation_error", - "Slot '" + definition.name() + "' violates pattern constraint.", - "slot_validation"); + && !trimmedValue.matches(definition.pattern())) { + throw constraintViolation(definition, trimmedValue); } - validateNumericConstraint(definition, value.trim()); + 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 static void validateNumericConstraint(PromptSlotDefinition definition, String value) { + 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 { - double numericValue = Double.parseDouble(value); - if (definition.minimum() != null && numericValue < definition.minimum()) { - throw new PromptComplianceCheckException( - "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", - "Slot '" + definition.name() + "' is larger than maximum.", - "slot_validation"); - } + numericValue = Double.parseDouble(value); } catch (NumberFormatException error) { - throw new PromptComplianceCheckException( - "slot_validation_error", - "Slot '" + definition.name() + "' is not numeric.", - "slot_validation"); + 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/metadata/TemplateMatchingPromptMetadataExtractor.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractor.java deleted file mode 100644 index a64c3527..00000000 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractor.java +++ /dev/null @@ -1,123 +0,0 @@ -package net.openan.a2at.sdk.server.metadata; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; -import net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import net.openan.a2at.sdk.server.model.PromptTemplateDefinition; -import net.openan.a2at.sdk.server.model.PromptTemplateSlotDefinition; - -/** - * Extracts prompt metadata by matching processed prompts against known templates. - * - * @since 2026-06 - */ -public final class TemplateMatchingPromptMetadataExtractor implements ServerPromptMetadataExtractor { - - private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{?\\s*([^{}]+?)\\s*\\}\\}?"); - private static final String SLOT_SENTINEL_PREFIX = "__A2AT_SLOT_"; - private static final String SLOT_SENTINEL_SUFFIX = "__"; - - private final List templates; - private final TaskPromptRenderer renderer; - - /** - * Creates a template-matching metadata extractor. - * - * @param templates known prompt templates - */ - public TemplateMatchingPromptMetadataExtractor(List templates) { - this(templates, new TaskPromptRenderer()); - } - - /** - * Creates a template-matching metadata extractor backed by the supplied renderer. - * - * @param templates known prompt templates - * @param renderer task prompt renderer used to normalize template structure - */ - public TemplateMatchingPromptMetadataExtractor(List templates, TaskPromptRenderer renderer) { - this.templates = List.copyOf(templates); - this.renderer = renderer; - } - - @Override - public ProcessedPromptMetadata extract(String processedPromptText) { - for (PromptTemplateDefinition template : templates) { - Optional> slots = tryExtractSlots(processedPromptText, template); - if (slots.isPresent()) { - validateRequiredSlots(slots.get(), template.slotDefinitions()); - return new ProcessedPromptMetadata( - template.scenarioCode(), template.language(), template.templateText(), Map.copyOf(slots.get())); - } - } - throw new PromptComplianceCheckException( - "processed_prompt_parse_error", "Prompt does not match any known template.", "prompt_parse"); - } - - private Optional> tryExtractSlots(String processedPromptText, PromptTemplateDefinition template) { - Matcher placeholderMatcher = PLACEHOLDER_PATTERN.matcher(template.templateText()); - List slotNames = new java.util.ArrayList<>(); - Map sentinelSlots = new LinkedHashMap<>(); - int slotIndex = 0; - while (placeholderMatcher.find()) { - String slotName = placeholderMatcher.group(1).trim(); - String sentinel = SLOT_SENTINEL_PREFIX + slotIndex + SLOT_SENTINEL_SUFFIX; - slotNames.add(slotName); - sentinelSlots.put(slotName, sentinel); - slotIndex++; - } - - final String normalizedTemplateText; - try { - normalizedTemplateText = renderer.render(template.templateText(), sentinelSlots); - } catch (TaskPromptRenderException error) { - throw new PromptComplianceCheckException("processed_prompt_parse_error", error.getMessage(), "prompt_parse"); - } - - StringBuilder pattern = new StringBuilder("^"); - int cursor = 0; - for (int index = 0; index < slotNames.size(); index++) { - String sentinel = sentinelSlots.get(slotNames.get(index)); - int sentinelIndex = normalizedTemplateText.indexOf(sentinel, cursor); - if (sentinelIndex < 0) { - return Optional.empty(); - } - pattern.append(Pattern.quote(normalizedTemplateText.substring(cursor, sentinelIndex))); - pattern.append("(.*?)"); - cursor = sentinelIndex + sentinel.length(); - } - pattern.append(Pattern.quote(normalizedTemplateText.substring(cursor))); - pattern.append("$"); - - Matcher match = Pattern.compile(pattern.toString(), Pattern.DOTALL).matcher(processedPromptText); - if (!match.matches()) { - return Optional.empty(); - } - - Map slots = new LinkedHashMap<>(); - for (int index = 0; index < slotNames.size(); index++) { - slots.put(slotNames.get(index), match.group(index + 1)); - } - return Optional.of(slots); - } - - private static void validateRequiredSlots( - Map slots, List slotDefinitions) { - for (PromptTemplateSlotDefinition definition : slotDefinitions) { - Optional value = Optional.ofNullable(slots.get(definition.name())); - if (definition.required() && !value.filter(slotValue -> !slotValue.isBlank()).isPresent()) { - throw new PromptComplianceCheckException( - "slot_validation_error", - "Required slot '" + definition.name() + "' is missing.", - "slot_validation"); - } - } - } -} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateDefinition.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateDefinition.java deleted file mode 100644 index 65e5ee53..00000000 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateDefinition.java +++ /dev/null @@ -1,18 +0,0 @@ -package net.openan.a2at.sdk.server.model; - -import java.util.List; - -/** - * Template candidate used to parse processed task prompts on the server side. - * - * @param scenarioCode scenario code associated with the template - * @param language language associated with the template - * @param templateText canonical template text - * @param slotDefinitions slot definitions expected by the template - * @since 2026-06 - */ -public record PromptTemplateDefinition( - String scenarioCode, - String language, - String templateText, - List slotDefinitions) {} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateSlotDefinition.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateSlotDefinition.java deleted file mode 100644 index 0c43f868..00000000 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/model/PromptTemplateSlotDefinition.java +++ /dev/null @@ -1,10 +0,0 @@ -package net.openan.a2at.sdk.server.model; - -/** - * Slot definition used by template-based server prompt compliance checks. - * - * @param name slot name - * @param required whether the slot is required - * @since 2026-06 - */ -public record PromptTemplateSlotDefinition(String name, boolean required) {} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/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/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..f9297c16 --- /dev/null +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidator.java @@ -0,0 +1,66 @@ +package net.openan.a2at.sdk.server.validation; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.exception.ErrorMessages; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.InputLimitConfig; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import org.jspecify.annotations.Nullable; + +/** + * Free-text input gate wrapped around one delegated content validator. + * + *

      The gate rejects an oversized prompt with the code {@code input.text_too_long} before the delegated pipeline + * starts, so no LLM call is made for an input that could not fit the LLM context anyway. The limit is configured + * through {@code A2AT_INPUT_TEXT_MAX_CHARS} and defaults to {@link InputLimitConfig#DEFAULT_MAX_TEXT_CHARS} characters. + * + * @since 2026-08 + */ +public final class InputLimitedContentValidator implements ContentValidator { + + private final ContentValidator delegate; + + private final int maxTextChars; + + private final String language; + + /** + * Creates one gating validator around the given delegate. + * + * @param delegate content validator carrying the actual validation pipeline + * @param maxTextChars maximum length in characters accepted for the prompt text + */ + public InputLimitedContentValidator(ContentValidator delegate, int maxTextChars) { + this(delegate, maxTextChars, null); + } + + /** + * Creates one gating validator around the given delegate with an explicit message language. + * + * @param delegate content validator carrying the actual validation pipeline + * @param maxTextChars maximum length in characters accepted for the prompt text + * @param language language used to render the rejection message, for example {@code zh-CN}; null falls back to + * {@code en-US} + */ + public InputLimitedContentValidator(ContentValidator delegate, int maxTextChars, @Nullable String language) { + this.delegate = delegate; + this.maxTextChars = maxTextChars; + this.language = language; + } + + @Override + public FilledParamData validate(String prompt, Map schema, TemplateUri templateUri) { + if (InputLimitConfig.isTooLong(prompt, maxTextChars)) { + Map facts = Map.of( + "actual_length", String.valueOf(prompt.length()), + "max_chars", String.valueOf(maxTextChars)); + throw new ContentValidationException( + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), + ErrorMessages.render(ErrorCatalog.INPUT_TEXT_TOO_LONG, language, facts)); + } + return delegate.validate(prompt, schema, templateUri); + } +} 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..d60c00bc 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidator.java @@ -1,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; @@ -7,21 +8,38 @@ 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 ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + 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; @@ -59,7 +77,7 @@ public void validate(String processedPromptText, ProcessedPromptMetadata metadat null, null) .content(); - validateResponse(payload); + validateResponse(payload, metadata.language()); } private String buildUserPrompt(PromptSlotSchema slotSchema, Map extractedSlots) { @@ -73,22 +91,25 @@ private String buildUserPrompt(PromptSlotSchema slotSchema, Map + OBJECT_MAPPER.writeValueAsString(extractedSlots) + "\n}"; } catch (JsonProcessingException error) { - throw new PromptComplianceCheckException( - "slot_validation_error", "Failed to serialize slot schema", "slot_validation"); + 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", "message")); + 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"), - "message", Map.of("type", "string"))); + "facts", factsSchema)); Map schema = new LinkedHashMap<>(); schema.put("type", "object"); @@ -108,41 +129,82 @@ 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) { + 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; } - Optional message = extractFirstMessage(errorsValue); + SemanticError firstError = firstError(errors); + if (firstError == null) { + throw responseInvalid(language); + } throw new PromptComplianceCheckException( - "slot_validation_error", - message.filter(text -> !text.isBlank()).orElse("Slot semantic validation failed."), - "slot_validation"); + resolveErrorCode(firstError.code()), language, firstError.facts(), SLOT_VALIDATION_STAGE); } - private static Map parseResponse(String payload) { + 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 new PromptComplianceCheckException( - "slot_validation_error", "semantic validation returned invalid JSON", "slot_validation"); + 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 Optional extractFirstMessage(Object errorsValue) { - if (!(errorsValue instanceof List errors) || errors.isEmpty()) { - return Optional.empty(); + private static Map stringFacts(Object factsValue) { + if (!(factsValue instanceof Map facts)) { + return Map.of(); } - Object firstError = errors.get(0); - if (!(firstError instanceof Map errorMap)) { - return Optional.empty(); + 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); + } } - Object message = errorMap.get("message"); - return message instanceof String text ? Optional.of(text) : Optional.empty(); + 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/main/java/net/openan/a2at/sdk/server/validation/TemplateRoundTripPromptSemanticValidator.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/TemplateRoundTripPromptSemanticValidator.java deleted file mode 100644 index 926107ba..00000000 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/validation/TemplateRoundTripPromptSemanticValidator.java +++ /dev/null @@ -1,46 +0,0 @@ -package net.openan.a2at.sdk.server.validation; - -import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; -import net.openan.a2at.sdk.prompt.taskrendering.exception.TaskPromptRenderException; -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; - -/** - * Validates semantic consistency by round-tripping extracted slots through the original template. - * - * @since 2026-06 - */ -public final class TemplateRoundTripPromptSemanticValidator implements ServerPromptSemanticValidator { - - private final TaskPromptRenderer renderer; - - /** Creates a semantic validator backed by the default task prompt renderer. */ - public TemplateRoundTripPromptSemanticValidator() { - this(new TaskPromptRenderer()); - } - - /** - * Creates a semantic validator backed by the supplied renderer. - * - * @param renderer task prompt renderer - */ - public TemplateRoundTripPromptSemanticValidator(TaskPromptRenderer renderer) { - this.renderer = renderer; - } - - @Override - public void validate(String processedPromptText, ProcessedPromptMetadata metadata) { - final String rendered; - try { - rendered = renderer.render(metadata.templateText(), metadata.slots()); - } catch (TaskPromptRenderException error) { - throw new PromptComplianceCheckException("slot_validation_error", error.getMessage(), "slot_validation"); - } - if (!processedPromptText.equals(rendered)) { - throw new PromptComplianceCheckException( - "slot_validation_error", - "Prompt is not semantically consistent with extracted slots.", - "slot_validation"); - } - } -} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java new file mode 100644 index 00000000..d5b45403 --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerContentValidationApiTest.java @@ -0,0 +1,144 @@ +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.Test; + +class A2ATServerContentValidationApiTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + @Test + void validateTaskPromptRejectsNullTemplateUriWithNullPointerException() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows( + NullPointerException.class, + () -> server.validateTaskPromptAndDataFilling("test prompt", Map.of(), null)); + } + + @Test + void validateNotificationPromptRejectsNullTemplateUriWithNullPointerException() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows( + NullPointerException.class, + () -> server.validateNotificationPromptAndDataFilling("test prompt", Map.of(), null)); + } + + @Test + void validateAuthPromptRejectsNullTemplateUriWithNullPointerException() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows( + NullPointerException.class, + () -> server.validateAuthPromptAndDataFilling("test prompt", Map.of(), null)); + } + + @Test + void generateNegotiationProposePromptFromDataStillThrowsOnNullTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows( + NullPointerException.class, + () -> server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + null)); + } + + @Test + void getPromptStillThrowsOnNullTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + assertThrows(NullPointerException.class, () -> server.getPrompt(null)); + } + + @Test + void generateNegotiationProposePromptFromDataRejectsMalformedTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + "not-a-template-uri")); + + assertTrue(error.getMessage().contains("Unparseable template URI")); + } + + @Test + void validateProposePromptAndDataFillingRejectsMalformedTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> server.validateProposePromptAndDataFilling("test prompt", null, Map.of(), "not-a-template-uri")); + + assertTrue(error.getMessage().contains("Unparseable template URI")); + } + + @Test + void validateTaskPromptAndDataFillingRejectsMalformedTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> server.validateTaskPromptAndDataFilling("test prompt", Map.of(), "Task-T/network-layer")); + + assertTrue(error.getMessage().contains("Unparseable template URI")); + } + + @Test + void getPromptRejectsMalformedTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> server.getPrompt("not-a-template-uri")); + + assertTrue(error.getMessage().contains("Unparseable template URI")); + } + + @Test + void getPromptRejectsBlankTemplateUri() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> server.getPrompt(" ")); + + assertTrue(error.getMessage().contains("Unparseable template URI")); + } + + private static Path writeEnv() throws IOException { + Path tempDir = Files.createTempDirectory("a2at-server-content-validation-env"); + Path envFile = tempDir.resolve("server.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_PROMPT_COMPLIANCE_ENABLED=false + """); + return envFile; + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java new file mode 100644 index 00000000..b75212c7 --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerNegotiationApiTest.java @@ -0,0 +1,143 @@ +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.PromptTemplate; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.server.A2ATServer; +import org.junit.jupiter.api.Test; + +class A2ATServerNegotiationApiTest { + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final TemplateUri INFORMATION_PROPOSE = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE; + + private static final String INFORMATION_PROPOSE_URI = INFORMATION_PROPOSE.uri(); + + @Test + void generatesInformationProposeFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + MetadataContent result = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("协商上下文"), "the context section must not be rendered"); + Map metadata = result.buildMetadataContent(); + assertEquals(3, metadata.size()); + assertEquals(result.promptText(), metadata.get(result.extensionUri())); + assertEquals(result.templateUri(), metadata.get(MetadataContent.TEMPLATE_URI_METADATA_KEY)); + assertEquals(new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), result.negotiationContext()); + } + + @Test + void generatesInformationProposeFromDataWithBuiltinEnglishTemplates() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("en-US")); + + MetadataContent result = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("Region", "Songshan Lake")), null)), + INFORMATION_PROPOSE_URI); + + assertEquals(INFORMATION_PROPOSE_URI, result.templateUri()); + assertFalse(result.promptText().isBlank()); + assertFalse(result.promptText().contains("Negotiation Context"), "the context section must not be rendered"); + assertTrue(result.promptText().contains("Required Information Items")); + } + + @Test + void listsAllSevenNegotiationTemplatesPerLanguage() throws IOException { + assertEquals(7, negotiationPrompts(new A2ATServer(writeEnv("zh-CN"))).size()); + assertEquals(7, negotiationPrompts(new A2ATServer(writeEnv("en-US"))).size()); + } + + @Test + void generatesAbortPromptFromDataWithBuiltinChineseTemplates() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + MetadataContent result = server.generateNegotiationAbortPromptFromData( + new NegotiationAbortData( + new NegotiationContext(UUID, 5, 5, NegotiationPerformative.ABORT), + new NegotiationAbortContent("达到协商轮次上限,本次协商确认结束。")), + StandardTemplates.NEGOTIATION_ABORT_URI); + + assertEquals(StandardTemplates.NEGOTIATION_ABORT.uri(), result.templateUri()); + assertTrue(result.promptText().contains("## 协商结果\nAbort")); + assertTrue(result.promptText().contains("## 协商终止原因")); + assertTrue(result.promptText().contains("达到协商轮次上限,本次协商确认结束。")); + } + + @Test + void queriesTheCommonAbortTemplateWithoutThrowing() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + PromptTemplate template = + server.getPrompt(StandardTemplates.NEGOTIATION_ABORT_URI).orElseThrow(); + assertEquals(StandardTemplates.NEGOTIATION_ABORT, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + @Test + void queriesSingleNegotiationTemplateWithoutThrowing() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("zh-CN")); + + assertTrue(server.getPrompt(INFORMATION_PROPOSE_URI).isPresent()); + assertTrue(server.getPrompt(StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI) + .isPresent()); + assertFalse(server.getPrompt( + StandardTemplates.NEGOTIATION_EXTENSION_NAME + "/unknown-negotiation/propose/v1") + .isPresent()); + PromptTemplate template = server.getPrompt(INFORMATION_PROPOSE_URI).orElseThrow(); + assertEquals(INFORMATION_PROPOSE, template.templateUri()); + assertFalse(template.content().isBlank()); + } + + private static List negotiationPrompts(A2ATServer server) { + return server.getPrompts().stream() + .filter(template -> StandardTemplates.NEGOTIATION_EXTENSION_NAME.equals( + template.templateUri().extensionName())) + .toList(); + } + + private static Path writeEnv(String language) throws IOException { + Path tempDir = Files.createTempDirectory("a2at-server-negotiation-env"); + Path envFile = tempDir.resolve("server.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=%s + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=openai + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_PROMPT_COMPLIANCE_ENABLED=false + """ + .formatted(language)); + return envFile; + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java new file mode 100644 index 00000000..58a20e16 --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java @@ -0,0 +1,166 @@ +package net.openan.a2at.sdk.server; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class A2ATServerObservabilityTest { + + private static final String MOCK_PROVIDER = "a2at-observability-mock"; + + public static final class NoopLLM implements LLMClient { + public NoopLLM(LLMClientConfig config) {} + + @Override + public LLMResponse structured( + List> messages, Map schema, Double temperature, Integer maxTokens) { + return new LLMResponse("{}", "mock", Map.of(), Map.of()); + } + } + + static final class RecordingHook implements TelemetryHook { + final List started = new ArrayList<>(); + final List succeeded = new ArrayList<>(); + final List rounds = new ArrayList<>(); + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + String name = role + "." + method; + started.add(name); + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + + @Override public void markSuccess() { + succeeded.add(name); + } + + @Override public void markError(Throwable error) { + started.add("FAILED:" + name); + } + + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + } + + static final class ThrowingHook implements TelemetryHook { + private final boolean throwOnStart; + + ThrowingHook(boolean throwOnStart) { + this.throwOnStart = throwOnStart; + } + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + if (throwOnStart) { + throw new IllegalStateException("startSdkOperation failed"); + } + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + + @Override public void markSuccess() {} + + @Override public void markError(Throwable error) { + throw new IllegalStateException("markError failed"); + } + + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) {} + } + + @TempDir + Path tempDir; + + private A2ATServer newServer(TelemetryHook hook) throws IOException { + if (!LLMClientFactory.availableProviders().contains(MOCK_PROVIDER)) { + LLMClientFactory.register(MOCK_PROVIDER, NoopLLM.class); + } + return new A2ATServer(writeEnv(hook == null), hook); + } + + private Path writeEnv(boolean legacy) throws IOException { + Path envFile = tempDir.resolve(legacy ? "server-legacy.env" : "server.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_PROMPT_COMPLIANCE_ENABLED=false + """ + .formatted(MOCK_PROVIDER)); + return envFile; + } + + @Test + void llmFreePathIsWrappedAsSdkOperation() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATServer server = newServer(hook); + server.getPrompts(); + assertTrue(hook.started.contains("server.getPrompts")); + assertTrue(hook.succeeded.contains("server.getPrompts")); + } + + @Test + void runtimeFailureMarksErrorAndPropagates() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATServer server = newServer(hook); + assertThrows(NullPointerException.class, () -> server.getPrompt(null)); + assertTrue(hook.started.contains("server.getPrompt")); + assertTrue(hook.started.contains("FAILED:server.getPrompt")); + } + + @Test + void throwingHookDoesNotBreakBusinessFlow() throws IOException { + A2ATServer startFailure = newServer(new ThrowingHook(true)); + assertDoesNotThrow(startFailure::getPrompts); + + A2ATServer markErrorFailure = newServer(new ThrowingHook(false)); + NullPointerException error = + assertThrows(NullPointerException.class, () -> markErrorFailure.getPrompt(null)); + assertEquals("templateUri", error.getMessage()); + } + + @Test + void legacyConstructorAndFacadeCallsComplete() throws IOException { + A2ATServer server = new A2ATServer(writeEnv(true)); + assertDoesNotThrow(server::getPrompts); + assertDoesNotThrow(server::getPrompts); + } + + @Test + void terminalOutcomeMapping() { + assertEquals("accept", A2ATServer.terminalOutcome(NegotiationStatus.AGREED)); + assertEquals("reject", A2ATServer.terminalOutcome(NegotiationStatus.REJECTED)); + assertEquals(null, A2ATServer.terminalOutcome(NegotiationStatus.IN_PROGRESS)); + } +} diff --git a/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..f52e4134 --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/InputTextLengthLimitTest.java @@ -0,0 +1,146 @@ +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class InputTextLengthLimitTest { + + private static final String TEST_MOCK_PROVIDER = "test-input-limit-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, CountingClient.class); + } + } + + @BeforeEach + void resetCounter() { + CountingClient.resetCallCount(); + } + + @TempDir + Path tempDir; + + @Test + void checkTaskPromptFailsFastWithoutLlmCallWhenInputExceedsConfiguredLimit() throws IOException { + A2ATServer server = new A2ATServer(writeEnv("10")); + String oversizedInput = "a".repeat(11); + + PromptComplianceResult result = server.checkTaskPrompt(oversizedInput); + + assertEquals(false, result.success()); + assertEquals( + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), result.failure().code()); + assertEquals("input_gate", result.failure().stage()); + assertEquals(0, CountingClient.callCount(), "No LLM call may happen for an oversized input"); + } + + @Test + void checkTaskPromptDoesNotFailForLengthWhenInputIsExactlyAtDefaultLimit() throws IOException { + A2ATServer server = new A2ATServer(writeEnv(null)); + String boundaryInput = "a".repeat(16384); + + PromptComplianceResult result = server.checkTaskPrompt(boundaryInput); + + assertNotEquals( + ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), + result.success() ? null : result.failure().code(), + "An input exactly at the default limit must not be rejected for its length"); + } + + @Test + void validateContentPromptAndDataFillingFailsFastWithoutLlmCallWhenPromptExceedsConfiguredLimit() + throws IOException { + A2ATServer server = new A2ATServer(writeEnv("10")); + String oversizedPrompt = "a".repeat(11); + Map schema = Map.of(); + + ContentValidationException taskError = assertThrows( + ContentValidationException.class, + () -> server.validateTaskPromptAndDataFilling( + oversizedPrompt, schema, StandardTemplates.ENERGY_SAVING_URI)); + ContentValidationException notificationError = assertThrows( + ContentValidationException.class, + () -> server.validateNotificationPromptAndDataFilling( + oversizedPrompt, schema, StandardTemplates.SUBSCRIBE_INCIDENT_URI)); + ContentValidationException authError = assertThrows( + ContentValidationException.class, + () -> server.validateAuthPromptAndDataFilling( + oversizedPrompt, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI)); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), taskError.getCode()); + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), notificationError.getCode()); + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), authError.getCode()); + assertEquals(0, CountingClient.callCount(), "No LLM call may happen for an oversized prompt"); + } + + private Path writeEnv(String maxTextChars) throws IOException { + Path envFile = tempDir.resolve("server.env"); + String limitEntry = maxTextChars == null ? "" : "A2AT_INPUT_TEXT_MAX_CHARS=" + maxTextChars + "\n"; + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-input-limit-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + + limitEntry); + return envFile; + } + + public static final class CountingClient implements LLMClient { + + private static final AtomicInteger CALL_COUNT = new AtomicInteger(0); + + private final LLMClientConfig config; + + public CountingClient(LLMClientConfig config) { + this.config = config; + } + + static void resetCallCount() { + CALL_COUNT.set(0); + } + + static int callCount() { + return CALL_COUNT.get(); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + CALL_COUNT.incrementAndGet(); + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} 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 new file mode 100644 index 00000000..bd1d2587 --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/TaskPromptNegotiationCoexistenceTest.java @@ -0,0 +1,151 @@ +package net.openan.a2at.sdk.server.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.MetadataContent; +import net.openan.a2at.sdk.core.model.NegotiationContext; +import net.openan.a2at.sdk.core.model.NegotiationPerformative; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.content.InformationProposeContent; +import net.openan.a2at.sdk.negotiation.content.NegotiationItem; +import net.openan.a2at.sdk.negotiation.content.NegotiationParamExtractionException; +import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; +import net.openan.a2at.sdk.negotiation.runtime.NegotiationHandler; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.server.A2ATServer; +import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Task-T and Negotiation-T coexistence regression: one server instance answers both the Task-T compliance API and the + * negotiation content-layer APIs without interference, and the pre-existing negotiation state machine keeps working + * next to the new APIs. + */ +class TaskPromptNegotiationCoexistenceTest { + + private static final String TEST_MOCK_PROVIDER = "test-coexistence-mock"; + + @BeforeAll + static void registerMockProvider() { + if (!LLMClientFactory.availableProviders().contains(TEST_MOCK_PROVIDER)) { + LLMClientFactory.register(TEST_MOCK_PROVIDER, RecordingClient.class); + } + } + + private static final String UUID = "3dbc13b5-bd57-4c2b-b503-24e381b6c8d3"; + + private static final String INFORMATION_PROPOSE_URI = StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI; + + private static final String TASK_T_MESSAGE = "## 任务类型(Task Type)\n" + + "无线能效优化\n\n" + + "## 任务对象(Task Object)\n" + + "松山湖管委会\n\n" + + "## 任务目标(Task Target)\n" + + "总功耗降低30%\n"; + + @TempDir + Path tempDir; + + @Test + void checkTaskPromptAndValidateAndFillingCoexistOnOneServerInstance() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + PromptComplianceResult complianceResult = server.checkTaskPrompt(TASK_T_MESSAGE); + + assertFalse(complianceResult.success()); + assertEquals("scenario.not_matched", complianceResult.failure().code()); + assertEquals("prompt_parse", complianceResult.failure().stage()); + + NegotiationParamExtractionException negotiationFailure = assertThrows( + NegotiationParamExtractionException.class, + () -> server.validateProposePromptAndDataFilling( + TASK_T_MESSAGE, + null, + Map.of("type", "object", "properties", Map.of("region", Map.of("type", "string"))), + INFORMATION_PROPOSE_URI)); + + assertEquals("negotiation.invalid_input", negotiationFailure.getCode()); + assertEquals("输入的协商内容无效:缺少协商上下文(该报文不是协商报文)", negotiationFailure.getMessage()); + + MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 1, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + assertEquals(INFORMATION_PROPOSE_URI, negotiationMessage.templateUri()); + assertEquals(UUID, negotiationMessage.negotiationContext().id()); + } + + @Test + void oldNegotiationStateMachineKeepsWorkingNextToTheContentLayerApis() throws IOException { + A2ATServer server = new A2ATServer(writeEnv()); + + Map started = server.startNegotiation( + NegotiationType.INFORMATION, "需要完整的任务提示词。", Map.of("source", "coexistence-test")); + Map context = (Map) started.get(NegotiationHandler.NEGOTIATION_T_URI_NL); + assertEquals("information", context.get("negotiationType")); + assertEquals("in-progress", context.get("status")); + assertEquals("coexistence-test", ((Map) started.get("facts")).get("source")); + + MetadataContent negotiationMessage = server.generateNegotiationProposePromptFromData( + new NegotiationProposeData( + new NegotiationContext(UUID, 2, 5, NegotiationPerformative.PROPOSE), + new InformationProposeContent(List.of(new NegotiationItem("节能区域", "松山湖")), null)), + INFORMATION_PROPOSE_URI); + assertEquals(2, negotiationMessage.negotiationContext().round()); + + Map startedAgain = server.startNegotiation(NegotiationType.TARGET, "请澄清目标。", Map.of()); + assertEquals( + "target", + ((Map) startedAgain.get(NegotiationHandler.NEGOTIATION_T_URI_NL)).get("negotiationType")); + } + + private Path writeEnv() throws IOException { + Path envFile = tempDir.resolve("server.env"); + Files.writeString( + envFile, + """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=test-coexistence-mock + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted()); + return envFile; + } + + public static final class RecordingClient implements LLMClient { + + private final LLMClientConfig config; + + public RecordingClient(LLMClientConfig config) { + this.config = config; + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + Double temperature, + Integer maxTokens) { + return new LLMResponse("{}", config.model(), Map.of(), Map.of()); + } + } +} 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..9b1087d1 --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/DefaultA2ATServerBuilderTest.java @@ -0,0 +1,198 @@ +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.InputLimitConfig; +import net.openan.a2at.sdk.core.model.LlmConfig; +import net.openan.a2at.sdk.core.model.NegotiationConfig; +import net.openan.a2at.sdk.core.model.PromptComplianceConfig; +import net.openan.a2at.sdk.core.model.PromptRuntimeConfig; +import net.openan.a2at.sdk.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, + 300, + 100, + false, + true, + false, + null, + 3, + List.of()), + new InputLimitConfig(InputLimitConfig.DEFAULT_MAX_TEXT_CHARS), + 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()); + } + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java deleted file mode 100644 index 90cb7e4a..00000000 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/LocalFileServerPromptTemplateLoaderTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package net.openan.a2at.sdk.server.assembly; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import net.openan.a2at.sdk.server.model.PromptTemplateDefinition; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class LocalFileServerPromptTemplateLoaderTest { - - @TempDir - Path promptRootDir; - - @Test - void loadReadsSlotDefinitionsUsingPromptPackagePublicLoaders() { - LocalFileServerPromptTemplateLoader loader = new LocalFileServerPromptTemplateLoader( - Path.of("..", "a2a-t-resources", "src", "main", "resources", "prompt_resources")); - - PromptTemplateDefinition definition = loader.load("energy_saving", "zh-CN"); - - assertEquals("energy_saving", definition.scenarioCode()); - assertEquals(4, definition.slotDefinitions().size()); - assertEquals(false, definition.slotDefinitions().get(0).required()); - } - - @Test - void loadAllScansLocalTemplateDirectoriesAndLoadsDefinitions() throws IOException { - write( - promptRootDir - .resolve("templates") - .resolve("incident_triage") - .resolve("en") - .resolve("template.md"), - """ - # Incident Triage - - Severity: {{severity}} - Summary: {{summary}} - """); - write( - promptRootDir - .resolve("slots") - .resolve("incident_triage") - .resolve("en") - .resolve("slot.json"), - """ - { - "required": ["severity"], - "properties": { - "severity": { - "type": "string", - "description": "Incident severity" - }, - "summary": { - "type": "string" - } - } - } - """); - - var definitions = new LocalFileServerPromptTemplateLoader(promptRootDir).loadAll("en"); - - assertEquals(1, definitions.size()); - PromptTemplateDefinition definition = definitions.get(0); - assertEquals("incident_triage", definition.scenarioCode()); - assertEquals("en", definition.language()); - assertEquals(2, definition.slotDefinitions().size()); - assertEquals("severity", definition.slotDefinitions().get(0).name()); - assertEquals(true, definition.slotDefinitions().get(0).required()); - } - - private static void write(Path file, String content) throws IOException { - Files.createDirectories(file.getParent()); - Files.writeString(file, content); - } -} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java index 10b358c4..2d53334c 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/assembly/ServerNegotiationOrchestratorBuilderTest.java @@ -21,7 +21,7 @@ void buildCreatesServerRoleOrchestratorThatUsesPromptComplianceForInformationNeg new RecordingPromptComplianceOrchestrator(new PromptComplianceResult( false, new PromptComplianceFailure( - "slot_validation_error", "Need more information", "slot_validation"))); + "slot.semantic_conflict", "Need more information", "slot_validation"))); RoleBoundNegotiationOrchestrator orchestrator = new ServerNegotiationOrchestratorBuilder() .promptComplianceOrchestrator(complianceOrchestrator) @@ -30,7 +30,7 @@ void buildCreatesServerRoleOrchestratorThatUsesPromptComplianceForInformationNeg Map started = orchestrator.startNegotiation(NegotiationType.INFORMATION, "Need full task prompt.", Map.of()); - Map context = cast(started.get(NegotiationHandler.NEGOTIATION_CONTEXT_KEY)); + Map context = cast(started.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); Map received = orchestrator.receiveNegotiation("latest full task prompt", context); @@ -47,10 +47,10 @@ void buildUsesServerRoleInStartPayload() { .build(); Map result = orchestrator.startNegotiation( - NegotiationType.CLARIFICATION, "Please clarify the target.", Map.of("site", "A")); - Map context = cast(result.get(NegotiationHandler.NEGOTIATION_CONTEXT_KEY)); + NegotiationType.TARGET, "Please clarify the target.", Map.of("site", "A")); + Map context = cast(result.get(NegotiationHandler.NEGOTIATION_T_URI_NL)); - assertEquals("clarification", context.get("negotiationType")); + assertEquals("target", context.get("negotiationType")); assertEquals("in-progress", context.get("status")); } diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java index da7dd49e..434b395f 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/compliance/DefaultServerPromptComplianceOrchestratorTest.java @@ -1,16 +1,15 @@ 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 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 { @@ -18,7 +17,7 @@ class DefaultServerPromptComplianceOrchestratorTest { @Test void checkTaskPromptReturnsSuccessWhenMetadataExtractionAndValidationPass() { ProcessedPromptMetadata metadata = - new ProcessedPromptMetadata("energy_saving", "en-US", "Site: {site}", Map.of("site", "Site A")); + 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 = @@ -38,34 +37,70 @@ void checkTaskPromptReturnsSuccessWhenMetadataExtractionAndValidationPass() { void checkTaskPromptReturnsFailureWhenMetadataExtractionFails() { DefaultServerPromptComplianceOrchestrator orchestrator = new DefaultServerPromptComplianceOrchestrator( new RecordingPromptMetadataExtractor(new PromptComplianceCheckException( - "processed_prompt_parse_error", "Prompt does not match any known template.", "prompt_parse")), + "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("processed_prompt_parse_error", result.failure().code()); + assertEquals("scenario.not_mapped", result.failure().code()); assertEquals("prompt_parse", result.failure().stage()); } @Test void checkTaskPromptReturnsFailureWhenSemanticValidationFails() { ProcessedPromptMetadata metadata = - new ProcessedPromptMetadata("energy_saving", "en-US", "Site: {site}", Map.of("site", "Site A")); + 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.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_validation_error", result.failure().code()); + 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; diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java index 263c862d..3e1b8123 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/LlmBackedPromptMetadataExtractorTest.java @@ -2,14 +2,16 @@ 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.impl.ScenarioRecognizer; 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; @@ -27,18 +29,19 @@ 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, null))); 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")), + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), "zh-CN", "Identify scenario.", "Choose scenario.", @@ -48,20 +51,42 @@ void extractResolvesScenarioLoadsTemplateAndReturnsExtractedSlots() { ProcessedPromptMetadata metadata = extractor.extract("## notification_topic\nIncident\n"); - assertEquals("subscribe_incident", metadata.scenarioCode()); + assertEquals("subscribe-incident", metadata.scenarioCode()); assertEquals("zh-CN", metadata.language()); assertEquals("## notification_topic\n{{notification_topic}}\n", metadata.templateText()); assertEquals(Map.of(SLOT_NAME, "Incident"), metadata.slots()); } @Test - void extractReturnsPromptParseErrorWhenScenarioRecognitionDoesNotMatch() { + void extractReturnsScenarioNotMatchedWhenScenarioRecognitionDoesNotMatch() { 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", + "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")), + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), "zh-CN", "Identify scenario.", "Choose scenario.", @@ -72,44 +97,129 @@ void extractReturnsPromptParseErrorWhenScenarioRecognitionDoesNotMatch() { PromptComplianceCheckException error = assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("unknown prompt")); - assertEquals("processed_prompt_parse_error", error.code()); - assertEquals("prompt_parse", error.stage()); + assertEquals("scenario.not_matched", error.getCode()); + assertEquals("prompt_parse", error.getStage()); } @Test - void extractReturnsSlotValidationErrorWhenStructuredExtractionReportsSlotErrors() { - LLMClient llmClient = - new RecordingClient("{\"matched\":true,\"scenario_code\":\"subscribe_incident\",\"error_message\":null}"); + void extractReturnsCatalogSlotCodeWhenStructuredExtractionReportsSlotErrors() { + 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")), + "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))), + 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")))); + 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_validation_error", error.code()); - assertEquals("slot_validation", error.stage()); + 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}"); + 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")), + "subscribe-incident", "Incident subscription", "Subscribe incident", "Subscribe Incident")), "zh-CN", "Identify scenario.", "Choose scenario.", @@ -122,8 +232,34 @@ 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()); + 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 { diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractorTest.java deleted file mode 100644 index 40c4c5f9..00000000 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/metadata/TemplateMatchingPromptMetadataExtractorTest.java +++ /dev/null @@ -1,165 +0,0 @@ -package net.openan.a2at.sdk.server.metadata; - -import net.openan.a2at.sdk.server.exception.PromptComplianceCheckException; -import net.openan.a2at.sdk.server.model.ProcessedPromptMetadata; -import net.openan.a2at.sdk.server.model.PromptTemplateDefinition; -import net.openan.a2at.sdk.server.model.PromptTemplateSlotDefinition; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.util.List; -import java.util.LinkedHashMap; -import java.util.Map; -import net.openan.a2at.sdk.prompt.taskrendering.api.TaskPromptRenderer; -import org.junit.jupiter.api.Test; - -class TemplateMatchingPromptMetadataExtractorTest { - - @Test - void extractReturnsScenarioLanguageTemplateAndSlotsForMatchedTemplate() { - TemplateMatchingPromptMetadataExtractor extractor = - new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "energy_saving", - "en-US", - "Site: {site}\nNotes: {additional_notes}", - List.of( - new PromptTemplateSlotDefinition("site", true), - new PromptTemplateSlotDefinition("additional_notes", false))))); - - ProcessedPromptMetadata metadata = extractor.extract("Site: Site A\nNotes: critical"); - - assertEquals("energy_saving", metadata.scenarioCode()); - assertEquals("en-US", metadata.language()); - assertEquals("Site: {site}\nNotes: {additional_notes}", metadata.templateText()); - assertEquals(Map.of("site", "Site A", "additional_notes", "critical"), metadata.slots()); - } - - @Test - void extractRejectsPromptThatDoesNotMatchAnyKnownTemplate() { - TemplateMatchingPromptMetadataExtractor extractor = - new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "energy_saving", - "en-US", - "Site: {site}", - List.of(new PromptTemplateSlotDefinition("site", true))))); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("Unknown prompt")); - - assertEquals("processed_prompt_parse_error", error.code()); - assertEquals("prompt_parse", error.stage()); - } - - @Test - void extractRejectsMissingRequiredSlotValueAfterTemplateMatch() { - TemplateMatchingPromptMetadataExtractor extractor = - new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "energy_saving", - "en-US", - "Site: {site}", - List.of(new PromptTemplateSlotDefinition("site", true))))); - - PromptComplianceCheckException error = - assertThrows(PromptComplianceCheckException.class, () -> extractor.extract("Site: ")); - - assertEquals("slot_validation_error", error.code()); - assertEquals("slot_validation", error.stage()); - } - - @Test - void extractMatchesTemplateWithDoubleBracedPlaceholders() { - TemplateMatchingPromptMetadataExtractor extractor = - new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "subscribe_incident", - "en-US", - "Topic: {{topic}}\nCondition: {{condition}}", - List.of( - new PromptTemplateSlotDefinition("topic", true), - new PromptTemplateSlotDefinition("condition", false))))); - - ProcessedPromptMetadata metadata = extractor.extract( - "Topic: Incident\nCondition: Severity is critical; alert type is flash"); - - assertEquals("subscribe_incident", metadata.scenarioCode()); - assertEquals("en-US", metadata.language()); - assertEquals( - Map.of( - "topic", "Incident", - "condition", "Severity is critical; alert type is flash"), - metadata.slots()); - } - - @Test - void extractMatchesCollapsedPromptAgainstOriginalTemplateWithDescriptiveLines() { - String originalTemplate = - "## Subscription Description\n" - + "Use the following topic, condition, report format, and expected output to complete an incident subscription task.\n\n" - + "## Topic\n" - + "{{topic}} (Required)\n" - + "Requirement: provide the incident topic name, such as Incident or Fault.\n\n" - + "## Condition\n" - + "{{condition}} (Optional)\n" - + "Requirement: include fault severity and fault name when needed.\n" - + "Severity supports values such as critical, high, medium, and low.\n" - + "Fault name supports network-side fault names such as pigtail failure, fiber cut, board failure, and optical module failure.\n\n" - + "## Report Format\n" - + "{{report_format}} (Optional)\n" - + "Requirement: specify the reported data type and the A2A Part type, such as DataPart or TextPart.\n" - + "Example: report incident data through DataPart.\n\n" - + "## Expected Output\n" - + "1. Subscription result, success or failure\n" - + "2. Failure reason when the subscription fails"; - String processedPrompt = - "## Subscription Description\n" - + "Use the following topic, condition, report format, and expected output to complete an incident subscription task.\n\n" - + "## Topic\n" - + "Incident\n\n" - + "## Condition\n" - + "Subscribe to ETH-LOS faults with critical severity\n\n" - + "## Report Format\n" - + "DataPart\n\n" - + "## Expected Output\n" - + "1. Subscription result, success or failure\n" - + "2. Failure reason when the subscription fails"; - Map sentinelSlots = new LinkedHashMap<>(); - sentinelSlots.put("topic", "__A2AT_SLOT_0__"); - sentinelSlots.put("condition", "__A2AT_SLOT_1__"); - sentinelSlots.put("report_format", "__A2AT_SLOT_2__"); - - assertEquals( - "## Subscription Description\n" - + "Use the following topic, condition, report format, and expected output to complete an incident subscription task.\n\n" - + "## Topic\n" - + "__A2AT_SLOT_0__\n\n" - + "## Condition\n" - + "__A2AT_SLOT_1__\n\n" - + "## Report Format\n" - + "__A2AT_SLOT_2__\n\n" - + "## Expected Output\n" - + "1. Subscription result, success or failure\n" - + "2. Failure reason when the subscription fails", - new TaskPromptRenderer().render(originalTemplate, sentinelSlots)); - - TemplateMatchingPromptMetadataExtractor extractor = - new TemplateMatchingPromptMetadataExtractor(List.of(new PromptTemplateDefinition( - "subscribe_incident", - "en-US", - originalTemplate, - List.of( - new PromptTemplateSlotDefinition("topic", true), - new PromptTemplateSlotDefinition("condition", false), - new PromptTemplateSlotDefinition("report_format", false))))); - - ProcessedPromptMetadata metadata = extractor.extract(processedPrompt); - - assertEquals("subscribe_incident", metadata.scenarioCode()); - assertEquals("en-US", metadata.language()); - assertEquals( - Map.of( - "topic", "Incident", - "condition", "Subscribe to ETH-LOS faults with critical severity", - "report_format", "DataPart"), - metadata.slots()); - } -} diff --git a/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..39f49e1b --- /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_URI)); + + 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()); + } + } +} 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..0a7f19de --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/InputLimitedContentValidatorTest.java @@ -0,0 +1,54 @@ +package net.openan.a2at.sdk.server.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import net.openan.a2at.sdk.core.exception.ErrorCatalog; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.model.StandardTemplates; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.core.validation.ContentValidator; +import org.junit.jupiter.api.Test; + +class InputLimitedContentValidatorTest { + + @Test + void validateThrowsInputTooLongWithoutDelegationWhenPromptExceedsLimit() { + RecordingContentValidator delegate = new RecordingContentValidator(); + InputLimitedContentValidator validator = new InputLimitedContentValidator(delegate, 5); + String oversizedPrompt = "a".repeat(6); + + ContentValidationException error = assertThrows( + ContentValidationException.class, + () -> validator.validate(oversizedPrompt, Map.of(), StandardTemplates.ENERGY_SAVING)); + + assertEquals(ErrorCatalog.INPUT_TEXT_TOO_LONG.getCode(), error.getCode()); + assertEquals("Input text length 6 exceeds the maximum of 5 (A2AT_INPUT_TEXT_MAX_CHARS)", error.getMessage()); + assertEquals(null, delegate.lastPrompt, "Delegate must not run for an oversized prompt"); + } + + @Test + void validateDelegatesWhenPromptIsExactlyAtLimit() { + RecordingContentValidator delegate = new RecordingContentValidator(); + InputLimitedContentValidator validator = new InputLimitedContentValidator(delegate, 5); + String boundaryPrompt = "a".repeat(5); + + FilledParamData result = validator.validate(boundaryPrompt, Map.of(), StandardTemplates.ENERGY_SAVING); + + assertEquals(boundaryPrompt, delegate.lastPrompt); + assertEquals(FilledParamData.class, result.getClass()); + } + + private static final class RecordingContentValidator implements ContentValidator { + + private String lastPrompt; + + @Override + public FilledParamData validate( + String prompt, Map schema, net.openan.a2at.sdk.core.model.TemplateUri templateUri) { + this.lastPrompt = prompt; + return new FilledParamData(Map.of()); + } + } +} diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java index 24b6d5b0..4c10d22f 100644 --- a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/validation/LlmBackedPromptSemanticValidatorTest.java @@ -3,6 +3,7 @@ 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; @@ -20,7 +21,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, null))); @Test void validatePassesWhenSemanticValidatorApprovesSlots() { @@ -31,7 +33,7 @@ void validatePassesWhenSemanticValidatorApprovesSlots() { assertDoesNotThrow(() -> validator.validate( "## notification_topic\nIncident\n", new ProcessedPromptMetadata( - "subscribe_incident", + "subscribe-incident", "zh-CN", "## notification_topic\n{{notification_topic}}\n", Map.of(SLOT_NAME, "Incident")))); @@ -52,17 +54,137 @@ void validatePassesWhenSemanticValidatorReturnsFormattedJson() { assertDoesNotThrow(() -> validator.validate( "## notification_topic\nIncident\n", new ProcessedPromptMetadata( - "subscribe_incident", + "subscribe-incident", "zh-CN", "## notification_topic\n{{notification_topic}}\n", Map.of(SLOT_NAME, "Incident")))); } @Test - void validateReturnsSlotValidationErrorWhenSemanticValidatorRejectsSlots() { - LLMClient llmClient = new RecordingClient( - "{\"passed\":false,\"errors\":[{\"slot_name\":\"notification_topic\"," - + "\"code\":\"semantic_mismatch\",\"message\":\"topic does not match\"}]}"); + 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"); @@ -71,13 +193,13 @@ void validateReturnsSlotValidationErrorWhenSemanticValidatorRejectsSlots() { () -> validator.validate( "## notification_topic\nIncident\n", new ProcessedPromptMetadata( - "subscribe_incident", + "subscribe-incident", "zh-CN", "## notification_topic\n{{notification_topic}}\n", Map.of(SLOT_NAME, "Incident")))); - assertEquals("slot_validation_error", error.code()); - assertEquals("slot_validation", error.stage()); + assertEquals("llm.response_invalid", error.getCode()); + assertEquals("slot_validation", error.getStage()); } private static final class RecordingClient implements LLMClient { diff --git a/client.env b/client.env deleted file mode 100644 index 8c86fec8..00000000 --- a/client.env +++ /dev/null @@ -1,10 +0,0 @@ -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=local_file -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=a2a-t-resources/src/main/resources/prompt_resources -A2AT_LLM_PROVIDER=openai_compatible -A2AT_LLM_MODEL= -A2AT_LLM_BASE_URL= -A2AT_LLM_API_KEY=${your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -REGISTRY_CENTER_HOST= -REGISTRY_CENTER_PORT= diff --git a/docs/en/API_Reference.md b/docs/en/API_Reference.md new file mode 100644 index 00000000..ac7c57b1 --- /dev/null +++ b/docs/en/API_Reference.md @@ -0,0 +1,1688 @@ +# 1 API Reference + +## 1.1 Introduction + +The A2A-T SDK exposes exactly two public entry points: the client entry `A2ATClient` (prompt generation, negotiation message generation and validation) and the server entry `A2ATServer` (prompt validation, negotiation message generation and validation). See the API category overview below. + +- **API Overview**: + +| Category | API | Client/Server | Description | LLM Involved | +| ---- | ------- | -------- | ------- |---------| +| Negotiation-T | `generateNegotiationProposePromptFromText` | A2A-T Client / A2A-T Server | Generates a negotiation propose message from natural-language text | Yes | +| Negotiation-T | `generateNegotiationAcceptPromptFromText` | A2A-T Client / A2A-T Server | Generates a negotiation accept message from natural-language text | Yes | +| Negotiation-T | `generateNegotiationRejectPromptFromText` | A2A-T Client / A2A-T Server | Generates a negotiation reject message from natural-language text | Yes | +| Negotiation-T | `generateNegotiationProposePromptFromData` | A2A-T Client / A2A-T Server | Deterministically generates a negotiation propose message from structured data | No | +| Negotiation-T | `generateNegotiationAcceptPromptFromData` | A2A-T Client / A2A-T Server | Deterministically generates a negotiation accept message from structured data | No | +| Negotiation-T | `generateNegotiationRejectPromptFromData` | A2A-T Client / A2A-T Server | Deterministically generates a negotiation reject message from structured data | No | +| Negotiation-T | `validateProposePromptAndDataFilling` | A2A-T Client / A2A-T Server | Validates a negotiation propose message and extracts parameters per a schema | Yes | +| Negotiation-T | `validateAcceptPromptAndDataFilling` | A2A-T Client / A2A-T Server | Validates a negotiation accept message and extracts parameters per a schema | Yes | +| Negotiation-T | `validateRejectPromptAndDataFilling` | A2A-T Client / A2A-T Server | Validates a negotiation reject message and extracts parameters per a schema | Yes | +| Task-T | `generateTaskPromptFromText` | A2A-T Client | Generates a task prompt from natural-language text with the specified Task-T template (skips scenario recognition) | Yes | +| Task-T | `generateTaskPromptFromDataWithSchema` | A2A-T Client | Generates a task prompt from structured data plus a semantic schema with the specified Task-T template | Yes | +| Task-T | `validateTaskPromptAndDataFilling` | A2A-T Server | Validates a Task-T task prompt and extracts parameters per a schema | Yes | +| Notification-T | `generateNotificationPromptFromText` | A2A-T Client | Generates a notification subscription prompt from natural-language text with the specified Notification-T template | Yes | +| Notification-T | `generateNotificationPromptFromDataWithSchema` | A2A-T Client | Generates a notification subscription prompt from structured data plus a semantic schema with the specified Notification-T template | Yes | +| Notification-T | `validateNotificationPromptAndDataFilling` | A2A-T Server | Validates a Notification-T prompt and extracts parameters per a schema | Yes | +| Authorization-T | `generateAuthPromptFromText` | A2A-T Client | Generates an authorization prompt from natural-language text with the specified Authorization-T template | Yes | +| Authorization-T | `generateAuthPromptFromDataWithSchema` | A2A-T Client | Generates an authorization prompt from structured data plus a semantic schema with the specified Authorization-T template | Yes | +| Authorization-T | `validateAuthPromptAndDataFilling` | A2A-T Server | Validates an Authorization-T prompt and extracts parameters per a schema | Yes | +| General | `generateTaskPrompt` | A2A-T Client | Generates a task prompt from natural-language or structured input via scenario recognition | Yes | +| General | `checkTaskPrompt` | A2A-T Server | Validates the scenario, template, and slot compliance of a task prompt | Yes | + +**Common Data Types and Conventions** + +- **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`; catching `A2ATError` covers all processing failures, and `getCode()` returns the machine-readable error code. All messages are rendered by the SDK from per-code message templates and follow `A2AT_LANGUAGE`. The following business exceptions extend `A2ATBusinessException` and additionally expose `getFacts()` carrying the structured fact values behind the rendered message: + - Generation failures throw `PromptGenerationException` (Task-T / Notification-T / Authorization-T) or `NegotiationGenerationException` (Negotiation-T); + - Validation-plus-extraction failures throw `ContentValidationException` (Task-T / Notification-T / Authorization-T) or `NegotiationParamExtractionException` (Negotiation-T). +- **SlotValidationError:** per-slot validation error details, returned with validation-failure exceptions or failure payloads (the `getErrors()` / `failedParameters()` / `errors()` mentioned in the output descriptions of the APIs all refer to this definition): + +| Field | Type | Description | +| ---- | ---- | ---- | +| slotName | String | Name of the slot in error | +| code | String | Slot-level error code, taken from the 1.4 error code list, e.g. `slot.not_provided`, `content.param_missing`, `content.entry_field_missing`, `content.format_error` | +| message | String | Human-readable error description, rendered by the SDK from the message template of the error code and following `A2AT_LANGUAGE` | +| facts | Map<String, String> | Structured fact values behind the rendered message (e.g. `section_label`, `index`, `field_label`), may be null | + +- **Template URI:** the `A2ATClient` and `A2ATServer` facades declare the template selection of every template-specific method as a plain `String templateUri` (e.g. `Task-T/network-layer/private-line-complaint/v1`). Prefer passing the `net.openan.a2at.sdk.core.model.StandardTemplates` String constants (constant name suffixed `_URI`, each with a typed `TemplateUri` twin named without the suffix); strings coming from outside the code are passed directly. The typed value type `TemplateUri` is still used by the lower-level service layer (e.g. `NegotiationContentService`), where it can be obtained with `TemplateUri.parse(String)`, which returns an `Optional` and never throws. The currently supported template URI constants (String form) are listed below: + +| Constant | Description | TemplateUri | +| ---- | ---- | ---- | +| StandardTemplates.ENERGY_SAVING_URI | Task-T energy-saving task template | Task-T/network-layer/ran-energy-saving/v1 | +| StandardTemplates.PRIVATE_LINE_COMPLAINT_URI | Task-T private-line complaint task template | Task-T/network-layer/private-line-complaint/v1 | +| StandardTemplates.SUBSCRIBE_INCIDENT_URI | Notification-T incident subscription notification template | Notification-T/network-layer/subscribe-incident/v1 | +| StandardTemplates.SERVICE_RECOVERY_URI | Notification-T service recovery notification template | Notification-T/network-layer/service-recovery/v1 | +| StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI | Authorization-T authorization policy management template | Authorization-T/authorization-policy-management/v1 | +| StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI | Negotiation-T information negotiation propose template | Negotiation-T/information-negotiation/propose/v1 | +| StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T information negotiation accept/reject template | Negotiation-T/information-negotiation/accept-reject/v1 | +| StandardTemplates.TARGET_NEGOTIATION_PROPOSE_URI | Negotiation-T target negotiation propose template | Negotiation-T/target-negotiation/propose/v1 | +| StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T target negotiation accept/reject template | Negotiation-T/target-negotiation/accept-reject/v1 | +| StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE_URI | Negotiation-T feasibility negotiation propose template | Negotiation-T/feasibility-negotiation/propose/v1 | +| StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T feasibility negotiation accept/reject template | Negotiation-T/feasibility-negotiation/accept-reject/v1 | +| StandardTemplates.NEGOTIATION_ABORT_URI | Negotiation-T common negotiation abort template | Negotiation-T/common/abort/v1 | + +- **Facade template URI failure policy:** every facade method that takes a `templateUri` parameter accepts a raw URI string. A null template URI throws `NullPointerException`; a blank or malformed URI (fewer than three segments, or a segment that is not simple) throws `IllegalArgumentException`. + + + +## 1.2 Constraints and Limitations + +- Some APIs involve LLM calls. Control the call rate and concurrency of these APIs according to the concurrency capacity of the connected model service and your business time-limit requirements. +- APIs that involve LLM calls guard the length of text `text` inputs: when the input exceeds the `A2AT_INPUT_TEXT_MAX_CHARS` characters configured in `client.env` or `server.env`, the error code `input.text_too_long` is reported; the default value of this configuration item is 16384 (16×1024). Structured data that involves no LLM calls is not subject to this limit. + +## 1.3 API Description + +### 1.3.1 generateNegotiationProposePromptFromText + +**API Definition** + +```java +public MetadataContent generateNegotiationProposePromptFromText( + String text, NegotiationContext context, String templateUri) +``` + +**Typical scenarios**: after receiving a Task-T task message with missing parameters, the server agent uses natural language to send a "supplement the missing information" negotiation request to the client agent; also applicable when the client agent initiates a target-clarification or feasibility-evaluation request to the server. + +**Function Description**: generates a structured negotiation propose-phase message from natural-language text. Execution flow: the template is loaded first, then one LLM content-extraction step runs, and finally the template is rendered deterministically. Applicable to the initiator of information/target/feasibility negotiations. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| text | String | Yes | Natural-language text describing the negotiation proposal (e.g. the list of missing information items to request); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | +| context | NegotiationContext | Yes | Negotiation session context, injected directly into the `negotiationContext` metadata of the generated message without going through the LLM | +| templateUri | String | Yes | Propose template, e.g. `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI` | + +**Request Example** + +```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( + "Please provide the following missing information: complaint category: private line interruption or poor private line quality. " + + "Both parameters are required; diagnosis cannot start without them.", + ctx, + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI); + +// The generated metadata travels with the A2A message +Map metadata = propose.buildMetadataContent(); +``` + +**Output** + +On success, returns `MetadataContent`: + +| Field/Method | Type | Description | +| --------- | ---- | ---- | +| templateUri | String | Template URI used to generate the message, e.g. `Negotiation-T/information-negotiation/propose/v1` | +| promptText | String | Rendered negotiation message text, carried as the value keyed by the extension URI in A2A message metadata | +| extensionUri | String | TMF extension URI (`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1`), i.e. the key of the message in metadata | +| negotiationContext | NegotiationContext | Negotiation session context (id / round / maxRounds), carried with the message without going through the LLM | +| buildMetadataContent() | Map<String, Object> | Builds the map that can be placed directly into `Message.metadata`: extension URI → message text, `templateUri` → template URI, `negotiationContext` → nested context object | + +On failure, throws `NegotiationGenerationException` (an `A2ATError` subclass, a runtime exception): + +| Member | Type | Description | +| ---- | ---- | ---- | +| getCode() | String | Machine-readable error code, see below | +| getMessage() | String | Human-readable failure description | +| getCause() | Throwable | Root cause, may be null | + +Error codes: + +- `template.not_found` (template or prompt resource missing) + +- `negotiation.content_extract_failed` (failed to extract structured content from the text, retryable) + +- `llm.invocation_failed` (LLM transport failure, retryable) + +- `llm.response_invalid` (LLM response violates the step contract, retryable) + +- `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 context or templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or a templateUri whose phase segment is not `propose` throws `IllegalArgumentException`; a null or blank text is not a programming error and throws `NegotiationGenerationException` with `negotiation.invalid_input` (see the error codes above). + +**Response Example** + +```text +templateUri : Negotiation-T/information-negotiation/propose/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 +promptText : +## Information Negotiation +Please supplement the relevant content based on . + +## Required Information Items +1. Complaint Category: e.g. dedicated-line quality degradation +``` + +### 1.3.2 generateNegotiationAcceptPromptFromText + +**API Definition** + +```java +public MetadataContent generateNegotiationAcceptPromptFromText( + String text, NegotiationContext context, String templateUri) +``` + +**Typical scenarios**: after receiving the peer's information-negotiation request, the negotiation responder (usually the client agent) supplements/delivers the requested information in natural language and generates an accept message to return, e.g. confirming that diagnosis can start after supplementing the access port name and complaint category. + +**Function Description**: generates a negotiation accept message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `ACCEPT`, otherwise it is rejected with `negotiation.conclusion_mismatch`) plus deterministic rendering. Applicable to the negotiation responder supplementing/delivering information. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| text | String | Yes | Natural-language text describing the acceptance (e.g. the list of supplemented/delivered information items); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | +| context | NegotiationContext | Yes | Negotiation session context | +| templateUri | String | Yes | Accept-reject template, e.g. `StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI` | + +**Request Example** + +```java +MetadataContent accept = client.generateNegotiationAcceptPromptFromText( + "I agree to supplement the following information: 1. Access port name: P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1; " + + "2. Complaint category: dedicated-line quality degradation. " + + "The information is complete and diagnosis can start.", + ctx, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); +``` + +**Output** + +On success, returns `MetadataContent` (structure same as [1.3.1](#131-generatenegotiationproposepromptfromtext)). + +On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). Error codes: + +- `template.not_found` (template or prompt resource missing) + +- `negotiation.content_extract_failed` (failed to extract structured content from the text, retryable) + +- `llm.invocation_failed` (LLM transport failure, retryable) + +- `llm.response_invalid` (LLM response violates the step contract, retryable) + +- `negotiation.invalid_input` (text is blank) + +- `negotiation.conclusion_mismatch` (the extracted conclusion is not `ACCEPT`) + +- `negotiation.field_missing` (a required field is missing) + +**Response Example** + +```text +templateUri : Negotiation-T/information-negotiation/accept-reject/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 +promptText : +## Information Negotiation Result +Accept + +## Information Negotiation Result Content +1. Access Port Name: P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1 +2. Complaint Category: dedicated-line quality degradation +``` + +### 1.3.3 generateNegotiationRejectPromptFromText + +**API Definition** + +```java +public MetadataContent generateNegotiationRejectPromptFromText( + String text, NegotiationContext context, String templateUri) +``` + +**Typical scenarios**: when the negotiation responder (usually the client agent) cannot satisfy the peer's negotiation request, it generates a reject message in natural language to return and end the current negotiation round, e.g. the access port name cannot be provided because the site inventory is unavailable. + +**Function Description**: generates a negotiation reject message from natural-language text. One LLM content-extraction step (the extracted conclusion must be `REJECT`, otherwise it is rejected with `negotiation.conclusion_mismatch`) plus deterministic rendering. + +**Input**: same as [generateNegotiationAcceptPromptFromText](#132-generatenegotiationacceptpromptfromtext), where text is natural language describing the rejection reason. + +**Request Example** + +```java +MetadataContent reject = client.generateNegotiationRejectPromptFromText( + "I refuse to supplement the information: the access port name cannot be provided because the site inventory is unavailable. This negotiation is over.", + ctx, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); +``` + +**Output** + +On success, returns `MetadataContent` (structure same as [1.3.1](#131-generatenegotiationproposepromptfromtext)). + +On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). Error codes: + +- `template.not_found` (template or prompt resource missing) + +- `negotiation.content_extract_failed` (failed to extract structured content from the text, retryable) + +- `llm.invocation_failed` (LLM transport failure, retryable) + +- `llm.response_invalid` (LLM response violates the step contract, retryable) + +- `negotiation.invalid_input` (text is blank) + +- `negotiation.conclusion_mismatch` (the extracted conclusion is not `REJECT`) + +- `negotiation.field_missing` (a required field is missing) + +**Response Example** + +```text +templateUri : Negotiation-T/information-negotiation/accept-reject/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 +promptText : +## Information Negotiation Result +Reject + +## Information Negotiation Result Content +1. Access Port Name: cannot be provided because the port inventory is temporarily unavailable on the workbench side +``` + +### 1.3.4 generateNegotiationProposePromptFromData + +**API Definition** + +```java +public MetadataContent generateNegotiationProposePromptFromData( + NegotiationProposeData data, String templateUri) +``` + +**Typical scenarios**: the same initiation scenarios as the fromText variant, but the input is structured data constructed by the business system (e.g. the server agent automatically generates the negotiation-request items from the missing-slot list detected by `validateTaskPromptAndDataFilling`), suitable for scenarios that require deterministic message content and want to avoid the nondeterminism of LLM extraction. + +**Function Description**: deterministically generates a negotiation propose message from structured-data input, **without calling the LLM**. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| data | `NegotiationProposeData(context, content)` | Yes | Negotiation context plus typed propose content; the content type depends on the negotiation type | +| templateUri | String | Yes | Propose template | + +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), `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** + +```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("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")), + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI); +``` + +**Output** + +On success, returns `MetadataContent` (structure same as [1.3.1](#131-generatenegotiationproposepromptfromtext); negotiation messages always carry `negotiationContext`). + +On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). Error codes: + +- `template.not_found` (template missing) + +- `negotiation.content_invalid` (a typed content field is invalid, e.g. blank required items or a blank required description) + +- `template.render_failed` (template rendering failure) + +There are also two categories of programming errors (outside the `A2ATError` tree, standard JDK exceptions): a null argument or a null context within it throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1), a content type that does not match the template's negotiation type, or a phase segment that is not `propose`, throws `IllegalArgumentException`. + +**Response Example** + +```text +templateUri : Negotiation-T/information-negotiation/propose/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 +promptText : +## Information Negotiation +Please supplement the relevant content based on . + +## Required Information Items +1. Access Port Name: e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1 +2. Complaint Category: e.g. dedicated-line quality degradation +3. Private Line Service Identifier +Relationship between missing items: OR +``` + +### 1.3.5 generateNegotiationAcceptPromptFromData + +**API Definition** + +```java +public MetadataContent generateNegotiationAcceptPromptFromData( + NegotiationEndingData data, String templateUri) +``` + +**Typical scenarios**: the negotiation responder (usually the client agent) programmatically fills in the parameters per the peer's requested slot list and generates an accept message from structured items to return. + +**Function Description**: deterministically generates a negotiation accept message from structured-data input, **without calling the LLM**. `content.conclusion()` must be `ACCEPT`; any other conclusion (including `ABORT`) is rejected with the `negotiation.conclusion_mismatch` business error. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| data | `NegotiationEndingData(context, content)` | Yes | Negotiation context plus typed accept content (conclusion is `ACCEPT`) | +| templateUri | String | Yes | Accept-reject template | + +Accept content of the three negotiation types: `InformationEndingContent(ACCEPT, items)` (list of delivered information items), `TargetEndingContent(ACCEPT, confirmedIntent, null)` (finally confirmed intent), `FeasibilityEndingContent(ACCEPT, feasibilitySummary)` (feasibility assessment conclusion summary). + +**Request Example** + +```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("Access Port Name", "P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1"), + new NegotiationItem("Complaint Category", "dedicated-line quality degradation")))), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); +``` + +**Output** + +On success, returns `MetadataContent` (structure same as [1.3.1](#131-generatenegotiationproposepromptfromtext)). + +On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). Error codes: + +- `template.not_found` (template missing) + +- `negotiation.content_invalid` (a typed content field is invalid, e.g. blank required items or a blank required description) + +- `template.render_failed` (template rendering failure) + +Programming errors: a null argument or a null context within it throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1), a mismatched content type or a phase segment that is not `accept-reject` throws `IllegalArgumentException`; a `conclusion` that is not `ACCEPT` is rejected with the `negotiation.conclusion_mismatch` business error. + +**Response Example** + +```text +templateUri : Negotiation-T/information-negotiation/accept-reject/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 +promptText : +## Information Negotiation Result +Accept + +## Information Negotiation Result Content +1. Access Port Name: P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1 +2. Complaint Category: dedicated-line quality degradation +``` + +### 1.3.6 generateNegotiationRejectPromptFromData + +**API Definition** + +```java +public MetadataContent generateNegotiationRejectPromptFromData( + NegotiationEndingData data, String templateUri) +``` + +**Typical scenarios**: the negotiation responder, after programmatically determining that the peer's request cannot be satisfied, generates a reject message from structured items (the items that cannot be provided and the reasons) to return. + +**Function Description**: deterministically generates a negotiation reject message from structured-data input, **without calling the LLM**. `content.conclusion()` must be `REJECT`; any other conclusion is rejected with the `negotiation.conclusion_mismatch` business error. + +**Input**: same as [generateNegotiationAcceptPromptFromData](#135-generatenegotiationacceptpromptfromdata), but with the conclusion `REJECT`. Reject content: `InformationEndingContent(REJECT, items)` (items that cannot be provided and the reasons), `TargetEndingContent(REJECT, null, failureReason)` (rejection reason), `FeasibilityEndingContent(REJECT, feasibilitySummary)` (infeasibility conclusion summary). + +**Request Example** + +```java +MetadataContent reject = client.generateNegotiationRejectPromptFromData( + new NegotiationEndingData( + ctx, + new InformationEndingContent( + NegotiationConclusion.REJECT, + List.of(new NegotiationItem("Access Port Name", "cannot be provided because the port inventory is temporarily unavailable on the workbench side")))), + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); +``` + +**Output** + +On success, returns `MetadataContent` (structure same as [1.3.1](#131-generatenegotiationproposepromptfromtext)). + +On failure, throws `NegotiationGenerationException` (structure same as 1.3.1). Error codes: + +- `template.not_found` (template missing) + +- `negotiation.content_invalid` (a typed content field is invalid, e.g. blank required items or a blank required description) + +- `template.render_failed` (template rendering failure) + +Programming errors: a null argument or a null context within it throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1), a mismatched content type or a phase segment that is not `accept-reject` throws `IllegalArgumentException`; a `conclusion` that is not `REJECT` is rejected with the `negotiation.conclusion_mismatch` business error. + +**Response Example** + +```text +templateUri : Negotiation-T/information-negotiation/accept-reject/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 +promptText : +## Information Negotiation Result +Reject + +## Information Negotiation Result Content +1. Access Port Name: cannot be provided because the port inventory is temporarily unavailable on the workbench side +``` + +### 1.3.7 validateProposePromptAndDataFilling + +**API Definition** + +```java +public FilledParamData validateProposePromptAndDataFilling( + String prompt, NegotiationContext context, Map schema, String templateUri) +``` + +**Typical scenarios**: an outbound self-check by the initiator (server agent) before sending a negotiation request; or the receiver (client agent) validating an inbound negotiation request and extracting the list of slots to supplement, driving the subsequent parameter filling. + +**Function Description**: validates whether a negotiation propose message is a properly formatted negotiation message, and extracts parameters from it per the caller-provided JSON Schema. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| prompt | String | Yes | Negotiation propose message text to validate (`MetadataContent.promptText()`); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | +| context | NegotiationContext | Yes | Negotiation context traveling with the message; a null value is reported as `negotiation.invalid_input` | +| schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema declaring the parameters to extract | +| templateUri | String | Yes | Propose template | + +**Request Example** + +```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( + "Access Port Name", Map.of("type", "string"), + "Complaint Category", Map.of("type", "string")), + "required", List.of("Access Port Name")); + +// proposePrompt is the negotiation propose message text sent by the peer +FilledParamData requested = client.validateProposePromptAndDataFilling( + proposePrompt, ctx, schema, StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI); + +// After filtering out the context parameters (id/round/maxRounds), the remaining keys +// are the slots to supplement +requested.data().keySet().removeAll(List.of("id", "round", "maxRounds")); +``` + +**Output** + +On success, returns `FilledParamData`: + +| Field/Method | Type | Description | +| --------- | ---- | ---- | +| data() | Map<String, Object> | Merged parameters: negotiation-context parameters (`id` / `round` / `maxRounds`, **winning** on key conflicts) + parameters extracted from the message per the caller's schema | + +On failure, throws `NegotiationParamExtractionException` (an `A2ATError` subclass): + +| Member | Type | Description | +| ---- | ---- | ---- | +| getCode() | String | Machine-readable error code, see below | +| getMessage() | String | Human-readable failure description | +| getErrors() | List<SlotValidationError> | Per-slot error details; structure defined in the common conventions | + +Error codes: + +- `negotiation.invalid_input` (the prompt is null or blank, the message is not a negotiation message, or the context is null) + +- `negotiation.rule_violation` (the negotiation context violates a rule; the nested slot error codes in `getErrors()` identify the concrete rule, e.g. `negotiation.invalid_context_id`, `negotiation.round_exceeded`) + +- `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`) + +- `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) + +- `template.not_found` (validation prompt resources missing) + +Programming errors: a null schema or templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or a mismatched phase segment throws `IllegalArgumentException`; a null or blank prompt is not a programming error and throws `NegotiationParamExtractionException` with `negotiation.invalid_input` (see the error codes above). + +**Response Example** + +```text +requested.data() = +{ + Access Port Name=e.g. P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1, + Complaint Category=e.g. dedicated-line quality degradation, + id=3dbc13b5-bd57-4c2b-b503-24e381b6c8d3, + round=1, + maxRounds=5 +} +``` + +### 1.3.8 validateAcceptPromptAndDataFilling + +**API Definition** + +```java +public FilledParamData validateAcceptPromptAndDataFilling( + String prompt, NegotiationContext context, Map schema, String templateUri) +``` + +**Typical scenarios**: the initiator (server agent) validates the accept message returned by the peer, extracts the delivered parameter values, and cross-checks them against the expected fill values before continuing task execution. + +**Function Description**: validates a negotiation accept message and extracts parameters per the schema. + +**Input**: same as 1.3.7, where prompt is the accept message text and templateUri is the accept-reject template. + +**Request Example** + +```java +import net.openan.a2at.sdk.server.A2ATServer; + +A2ATServer server = new A2ATServer(Path.of("server.env")); + +// acceptPrompt is the accept message text returned by the client; +// acceptContext is its negotiation context +FilledParamData acceptParams = server.validateAcceptPromptAndDataFilling( + acceptPrompt, acceptContext, schema, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); +``` + +**Output** + +On success, returns `FilledParamData` (structure same as [1.3.7](#137-validateproposepromptanddatafilling)); `data()` carries the parameters extracted from the delivered content per the schema plus the context parameters. + +On failure, throws `NegotiationParamExtractionException` (structure same as 1.3.7). Error codes: + +- `negotiation.invalid_input` (the prompt is null or blank, the message is not an accept negotiation message, or the context is null) + +- `negotiation.rule_violation` (the negotiation context violates a rule; the nested slot error codes in `getErrors()` identify the concrete rule, e.g. `negotiation.invalid_context_id`, `negotiation.round_exceeded`) + +- `negotiation.semantic_rejected` (the conclusion is not Accept, or the content does not satisfy the accept-phase constraints) + +- `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) + +- `template.not_found` (validation prompt resources missing) + +Programming errors: a null schema or templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or a phase segment that is not `accept-reject` throws `IllegalArgumentException`; a null or blank prompt is not a programming error and throws `NegotiationParamExtractionException` with `negotiation.invalid_input` (see the error codes above). + +**Response Example** + +```text +acceptParams.data() = +{ + Access Port Name=P533-Zhujiang Old Town-PTN3900-23-TPA1EG24-1, + Complaint Category=dedicated-line quality degradation, + id=3dbc13b5-bd57-4c2b-b503-24e381b6c8d3, + round=1, + maxRounds=5 +} +``` + +### 1.3.9 validateRejectPromptAndDataFilling + +**API Definition** + +```java +public FilledParamData validateRejectPromptAndDataFilling( + String prompt, NegotiationContext context, Map schema, String templateUri) +``` + +**Typical scenarios**: the initiator (server agent) validates the reject message returned by the peer, extracts the rejection reason, and terminates the task or escalates to manual handling accordingly. + +**Function Description**: validates a negotiation reject message and extracts parameters per the schema. + +**Input**: same as 1.3.7, where prompt is the reject message text and templateUri is the accept-reject template. + +**Request Example** + +```java +FilledParamData rejectParams = server.validateRejectPromptAndDataFilling( + rejectPrompt, ctx, schema, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); +``` + +**Output** + +On success, returns `FilledParamData` (structure same as [1.3.7](#137-validateproposepromptanddatafilling)); `data()` carries the parameters extracted from the rejection reason per the schema plus the context parameters. + +On failure, throws `NegotiationParamExtractionException` (structure same as 1.3.7). Error codes: + +- `negotiation.invalid_input` (the prompt is null or blank, the message is not a reject negotiation message, or the context is null) + +- `negotiation.rule_violation` (the negotiation context violates a rule; the nested slot error codes in `getErrors()` identify the concrete rule, e.g. `negotiation.invalid_context_id`, `negotiation.round_exceeded`) + +- `negotiation.semantic_rejected` (the conclusion is not Reject, or the content does not satisfy the reject-phase constraints) + +- `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) + +- `template.not_found` (validation prompt resources missing) + +Programming errors: a null schema or templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or a phase segment that is not `accept-reject` throws `IllegalArgumentException`; a null or blank prompt is not a programming error and throws `NegotiationParamExtractionException` with `negotiation.invalid_input` (see the error codes above). + +**Response Example** + +```text +rejectParams.data() = +{ + Access Port Name=cannot be provided because the port inventory is temporarily unavailable on the workbench side, + id=3dbc13b5-bd57-4c2b-b503-24e381b6c8d3, + round=1, + maxRounds=5 +} +``` + +### 1.3.10 generateTaskPromptFromText + +**API Definition** + +```java +public MetadataContent generateTaskPromptFromText(String text, String templateUri) +``` + +**Typical scenarios**: the client agent converts the user's natural-language task description (e.g. a private-line complaint diagnosis request) into a Task-T protocol message for a specified scenario; suitable when the target template is already known and scenario recognition should be skipped. + +**Function Description**: generates a task prompt message from natural-language text with the specified Task-T template, **skipping scenario recognition** (the template is explicitly specified by the caller). One LLM slot-extraction step runs, then the template is rendered deterministically. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| text | String | Yes | Natural-language task description; the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | +| templateUri | String | Yes | Task-T template, e.g. `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI` (`Task-T/network-layer/private-line-complaint/v1`) | + +**Request Example** + +```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( + "Help me create a private-line complaint diagnosis task. The port is P781-Zhujiang New Town-PTN7900-23-TPA1EG24-17, " + + "the customer reports poor private line quality. Starting from 8:30 AM on May 11, 2026, accessing the core " + + "systems in Guangzhou from Shenzhen has been extremely slow, latency jumped from 12ms to 320ms, the counter " + + "and mobile banking keep reporting connection timeouts, and the OSS sequence number is event-id-20260511-09013.", + StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); +``` + +**Output** + +On success, returns `MetadataContent`: + +| Field/Method | Type | Description | +| --------- | ---- | ---- | +| templateUri | String | Template URI used to generate the message, e.g. `Task-T/network-layer/private-line-complaint/v1` | +| promptText | String | Rendered task prompt message text, carried as the value keyed by the extension URI in A2A message metadata | +| extensionUri | String | TMF extension URI (`https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1`), i.e. the key of the message in metadata | +| negotiationContext | NegotiationContext | Always null (not a negotiation message) | +| buildMetadataContent() | Map<String, Object> | Builds the two-key map that can be placed directly into `Message.metadata`: extension URI → message text, `templateUri` → template URI | + +On failure, throws `PromptGenerationException` (an `A2ATError` subclass, a runtime exception): + +| Member | Type | Description | +| ---- | ---- | ---- | +| 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-domain failure codes such as `slot.not_provided`); structure defined in the common conventions | + +Error codes: + +- `template.not_found` (template missing) + +- `template.load_failed` (prompt resource load failure) + +- `slot.schema_not_found` (slot schema missing) + +- `llm.invocation_failed` / `llm.response_invalid` (LLM call failures, retryable) + +- `slot.not_provided` (a required slot is not provided in the input) + +- `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`; a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`. + +**Response Example** + +```text +templateUri : Task-T/network-layer/private-line-complaint/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1 +promptText : +## Task Type +Transport private line service complaint diagnosis + +## Task Description +Based on and , perform network-side fault root cause diagnosis in the complaint scenario, achieve the complaint diagnosis goal defined in , and return the task processing result in the structure defined in . + +## Task Target +Diagnose network-side faults and return diagnostic result information such as fault root causes and repair suggestions. + +## Task Object +Access port name: P781-Zhujiang New Town-PTN7900-23-TPA1EG24-17 + +## Task Context +1. Complaint category: poor private line quality +2. Problem occurrence time: 2026-05-11T08:21:46Z +3. OSS-side event sequence number: event-id-20260511-09013 +4. Complaint details: Starting from 8:30 on May 11, the response latency from SZ to GZ suddenly increased from an average of 12ms to 320ms + +## Expected Output +Requirement: The complaint diagnosis task result should include the following information: +1. Diagnosis result. Allowed values: success, failure (required) +2. Diagnosis result details (required) +3. Repair suggestions (optional) +4. Fault root cause list, where each fault root cause includes fault root cause name, detailed description, repair suggestions, fault root cause point location, etc. (optional) + +## Terminology Explanation +1. Private line interruption + - Synonyms: private line interruption, service interruption, network unreachable, service down, service inaccessible +2. Poor private line quality + - Synonyms: private line poor quality, service stutter, service access timeout, service packet loss, service high latency, service jitter, service congestion, service experience degradation, service high error rate, service optical power abnormal +``` + +### 1.3.11 generateTaskPromptFromDataWithSchema + +**API Definition** + +```java +public MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, String templateUri) +``` + +**Typical scenarios**: the client agent converts structured task parameters from an upstream system (field names may differ from the template slots; the schema describes the field semantics) into a Task-T protocol message; suitable when the task parameters are already available in structured form. + +**Function Description**: generates a task prompt from structured data plus a semantic schema with the specified Task-T template, **skipping scenario recognition**. The `schema` describes the business meaning of each input field (description / examples / enum, etc.), guiding slot filling and value constraints; each key of data corresponds to one slot value. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| data | Map<String, Object> | Yes | Structured business-field input; keys are business field names and values are field values | +| schema | Map<String, Object> | Yes (non-empty) | Field-semantics JSON Schema describing the meaning and constraints of each field | +| templateUri | String | Yes | Task-T template | + +**Request Example** + +```java +import java.util.Map; + +Map data = Map.of( + "portName", "P781-Futian Center-PTN7900-2-TPA1EG24-03", + "complaintScenario", "poor private line quality", + "faultStartTime", "2026-05-11T08:21:46Z", + "ticketNo", "event-id-20260511-09013", + "faultDetailText", "Starting from 8:30 on May 11, the response latency from SZ to GZ suddenly increased from an average of 12ms to 320ms"); + +Map semanticsSchema = Map.of( + "type", "object", + "properties", Map.of( + "portName", Map.of( + "type", "string", + "description", "Business field: access port name, which uniquely identifies the complained private line object"), + "complaintScenario", Map.of( + "type", "string", + "description", "Business field: complaint category scenario; one of private line interruption and poor private line quality is required", + "enum", List.of("private line interruption", "poor private line quality")), + "faultStartTime", Map.of("type", "string", "description", "Business field: problem occurrence time"), + "ticketNo", Map.of("type", "string", "description", "Business field: complaint work order or event sequence number received on the OSS side"), + "faultDetailText", Map.of("type", "string", "description", "Business field: the user's free description of the fault phenomenon")), + "required", List.of("portName", "complaintScenario")); + +MetadataContent metadata = client.generateTaskPromptFromDataWithSchema( + data, semanticsSchema, StandardTemplates.PRIVATE_LINE_COMPLAINT_URI); +``` + +**Output** + +On success, returns `MetadataContent` (structure same as [1.3.10](#1310-generatetaskpromptfromtext)). + +On failure, throws `PromptGenerationException` (structure same as 1.3.10). Programming errors: a null argument throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or an empty schema map throws `IllegalArgumentException`. + +**Response Example** (same template as 1.3.10; slot values come from the structured input, and `faultDetail` is a truncated sample value) + +```text +templateUri : Task-T/network-layer/private-line-complaint/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1 +promptText : +## Task Type +Transport private line service complaint diagnosis + +## Task Description +Based on and , perform network-side fault root cause diagnosis in the complaint scenario, achieve the complaint diagnosis goal defined in , and return the task processing result in the structure defined in . + +## Task Target +Diagnose network-side faults and return diagnostic result information such as fault root causes and repair suggestions. + +## Task Object +Access port name: P781-Futian Center-PTN7900-2-TPA1EG24-03 + +## Task Context +1. Complaint category: poor private line quality +2. Problem occurrence time: 2026-05-11T08:21:46Z +3. OSS-side event sequence number: event-id-20260511-09013 +4. Complaint details: Starting from 8:30 on May 11, the response latency from SZ to GZ suddenly increased from an average of 12ms to 320ms + +## Expected Output +Requirement: The complaint diagnosis task result should include the following information: +1. Diagnosis result. Allowed values: success, failure (required) +2. Diagnosis result details (required) +3. Repair suggestions (optional) +4. Fault root cause list, where each fault root cause includes fault root cause name, detailed description, repair suggestions, fault root cause point location, etc. (optional) + +## Terminology Explanation +1. Private line interruption + - Synonyms: private line interruption, service interruption, network unreachable, service down, service inaccessible +2. Poor private line quality + - Synonyms: private line poor quality, service stutter, service access timeout, service packet loss, service high latency, service jitter, service congestion, service experience degradation, service high error rate, service optical power abnormal +``` + +### 1.3.12 validateTaskPromptAndDataFilling + +**API Definition** + +```java +public FilledParamData validateTaskPromptAndDataFilling( + String prompt, Map schema, String templateUri) +``` + +**Typical scenarios**: the parameter validation and extraction entry point for the server agent after receiving a Task-T message and before business execution; also the decision point for "missing-slot detection" in the negotiation flow — when a required parameter is missing, the caller decides whether to initiate a negotiation to supplement it. + +**Function Description**: validates whether a Task-T task prompt message matches the template and slot constraints, and extracts parameters per the caller-provided JSON Schema. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| prompt | String | Yes (non-blank) | Task prompt message text to validate (`MetadataContent.promptText()`); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | +| schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema declaring the parameters to extract/validate and the required constraints | +| templateUri | String | Yes | Task-T template (the prefix segment must be `Task-T`) | + +**Request Example** + +```java +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.server.A2ATServer; + +A2ATServer server = new A2ATServer(Path.of("server.env")); + +// Server-side parameter schema (keys are server business field names, which may differ from +// the client field names; the SDK performs the cross-field adaptation) +Map validationSchema = Map.of( + "type", "object", + "properties", Map.of( + "accessPort", Map.of("type", "string", "description", "Access port name, which uniquely identifies the complained private line object"), + "bizScenario", Map.of( + "type", "string", + "description", "Complaint category scenario, required; only the values private line interruption and poor private line quality are allowed", + "enum", List.of("private line interruption", "poor private line quality")), + "faultTime", Map.of("type", "string", "description", "Problem occurrence time"), + "eventSerialNo", Map.of("type", "string", "description", "Complaint work order or event sequence number received on the OSS side"), + "faultDetail", Map.of("type", "string", "description", "Complaint/fault phenomenon description")), + "required", List.of("accessPort", "bizScenario")); + +Map extracted = server + .validateTaskPromptAndDataFilling( + metadata.promptText(), validationSchema, StandardTemplates.PRIVATE_LINE_COMPLAINT_URI) + .data(); +``` + +**Output** + +On success, returns `FilledParamData`: + +| Field/Method | Type | Description | +| --------- | ---- | ---- | +| data() | Map<String, Object> | Parameters extracted per the schema; keys are the parameter names declared in the schema | + +On failure, throws `ContentValidationException` (an `A2ATError` subclass): + +| Member | Type | Description | +| ---- | ---- | ---- | +| 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 `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: + +- `negotiation.invalid_input` (the prompt is null or blank, the schema is null, or the templateUri prefix segment/version does not match this API) + +- `negotiation.semantic_rejected` (semantic validation rejected, including missing or invalid required parameters; the per-slot details in `errors()` use the `content.*` codes, e.g. `content.param_missing`, `content.entry_field_missing`, `content.format_error`, `content.value_not_allowed`) + +- `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) + +- `template.not_found` (validation prompt resources missing) + +- `input.text_too_long` (prompt longer than `A2AT_INPUT_TEXT_MAX_CHARS`) + +Programming errors: a null templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`; a null or blank prompt or a null schema is not a programming error and throws `ContentValidationException` with `negotiation.invalid_input` (see the error codes above). + +**Response Example** (`faultTime` and `faultDetail` are truncated sample values) + +```text +extracted = +{ + accessPort=P781-Zhujiang New Town-PTN7900-23-TPA1EG24-17, + bizScenario=poor private line quality, + faultTime=2026-05-11, + eventSerialNo=event-id-20260511-09013, + faultDetail=320ms +} +``` + +On validation rejection (a negative sample with a missing key slot, from case 3 of `TaskTDemoMain`): + +```text +ContentValidationException: [negotiation.semantic_rejected] ... + slot=Task Object code=content.param_missing message=... facts={section_label=Task Object} +``` + +### 1.3.13 generateNotificationPromptFromText + +**API Definition** + +```java +public MetadataContent generateNotificationPromptFromText(String text, String templateUri) +``` + +**Typical scenarios**: the client agent converts a natural-language subscription requirement (e.g. a service recovery event subscription) into a Notification-T subscription message for a specified scenario. + +**Function Description**: generates a notification subscription prompt message from natural-language text with the specified Notification-T template. One LLM slot-extraction step runs, then the template is rendered deterministically, and the built-in slot schema is validated during generation. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| text | String | Yes | Natural-language subscription description (notification topic, subscribe condition, notification data format, etc.); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | +| templateUri | String | Yes | Notification-T template, e.g. `StandardTemplates.SUBSCRIBE_INCIDENT_URI`, `StandardTemplates.SERVICE_RECOVERY_URI` | + +**Request Example** + +```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")); +String templateUri = StandardTemplates.SERVICE_RECOVERY_URI; + +MetadataContent result = client.generateNotificationPromptFromText( + "I want to subscribe to service recovery events. The notification data format is as follows: " + + "1. Service recovery plan execution status, allowed values: not started, ended; " + + "2. Complaint diagnosis task sequence number; 3. OSS-side event sequence number; 4. Access port name; " + + "5. Whether OMC automatic recovery is authorized, allowed values: yes, no; " + + "6. Service recovery plan name; 7. Service recovery plan details", + templateUri); +``` + +**Output** + +On success, returns `MetadataContent` (structure same as [1.3.10](#1310-generatetaskpromptfromtext); `extensionUri` is `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1`). + +On failure, throws `PromptGenerationException` (structure same as 1.3.10). Programming errors: a null text or templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`. + +**Response Example** (rendered per the template; the actual text varies with the LLM slot-extraction result. This example input does not specify a subscribe condition, so that slot is left empty) + +```text +templateUri : Notification-T/network-layer/service-recovery/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1 +promptText : +## Subscription Description +Please complete the network-side service recovery event subscription and reporting task based on the following , , , and information. + +## Notification Topic +Service recovery event + +## Subscribe Condition + +## Notification Data Format +1. Service recovery plan execution status. Allowed values: not started, ended +2. Complaint diagnosis task sequence number +3. OSS-side event sequence number +4. Access port name +5. Whether OMC automatic recovery is authorized. Allowed values: yes, no +6. Service recovery plan name +7. Service recovery plan details + +## Expected Output +1. Subscription result. Allowed values: success +2. After successful subscription, report messages according to +``` + +### 1.3.14 generateNotificationPromptFromDataWithSchema + +**API Definition** + +```java +public MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, String templateUri) +``` + +**Typical scenarios**: the client agent converts structured subscription parameters submitted by an upstream system or UI into a Notification-T subscription message; suitable when the subscription parameters are already available in structured form. + +**Function Description**: generates a notification subscription prompt from structured data plus a semantic schema with the specified Notification-T template. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| data | Map<String, Object> | Yes | Structured subscription input (e.g. the subscribe condition and the notification data format field list) | +| schema | Map<String, Object> | Yes (non-empty) | Field-semantics JSON Schema | +| templateUri | String | Yes | Notification-T template | + +**Request Example** + +```java +Map data = Map.of( + "condition", "Subnetwork name: xx subnetwork", + "reportFormat", List.of( + Map.of("name", "Service recovery plan execution status", "values", List.of("not started", "ended"), "required", true), + Map.of("name", "Complaint diagnosis task sequence number", "required", true), + Map.of("name", "OSS-side event sequence number", "required", true), + Map.of("name", "Access port name", "required", true), + Map.of("name", "Whether OMC automatic recovery is authorized", "values", List.of("yes", "no"), "required", true), + Map.of("name", "Service recovery plan name", "required", true), + Map.of("name", "Service recovery plan details", "required", true), + Map.of("name", "Service recovery plan execution end time", "required", false))); + +Map dataSchema = Map.of( + "type", "object", + "properties", Map.of( + "condition", Map.of("type", "string", "description", "Subscribe condition, optional. The condition description to subscribe to."), + "reportFormat", Map.of( + "type", "array", + "description", "Notification data format, required. Describes the field list of the content to report.", + "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); +``` + +**Output** + +On success, returns `MetadataContent` (structure same as [1.3.10](#1310-generatetaskpromptfromtext); `extensionUri` is `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1`). + +On failure, throws `PromptGenerationException` (structure same as 1.3.10). Programming errors: a null argument throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or an empty schema map throws `IllegalArgumentException`. + +**Response Example** (same template as 1.3.13; slot values come from the structured input: `Subscribe Condition` is filled with `condition`, and `Notification Data Format` is rendered per the `reportFormat` list — field layout is illustrative) + +```text +templateUri : Notification-T/network-layer/service-recovery/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1 +promptText : +## Subscription Description +Please complete the network-side service recovery event subscription and reporting task based on the following , , , and information. + +## Notification Topic +Service recovery event + +## Subscribe Condition +Subnetwork name: xx subnetwork + +## Notification Data Format +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) + +## Expected Output +1. Subscription result. Allowed values: success +2. After successful subscription, report messages according to +``` + +### 1.3.15 validateNotificationPromptAndDataFilling + +**API Definition** + +```java +public FilledParamData validateNotificationPromptAndDataFilling( + String prompt, Map schema, String templateUri) +``` + +**Typical scenarios**: the server agent validates a received Notification-T subscription message, extracts the subscription parameters (topic/condition/report format), and establishes the subscription accordingly. + +**Function Description**: validates whether a Notification-T notification subscription prompt message matches the template and slot constraints, and extracts parameters per the caller's schema (subscription topic, subscribe condition, notification data format, etc.). + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| prompt | String | Yes (non-blank) | Notification subscription prompt message text to validate; the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | +| schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema | +| templateUri | String | Yes | Notification-T template (the prefix segment must be `Notification-T`) | + +**Request Example** + +```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", "Subscription topic (required). The name of the event topic to subscribe to."), + "subscriptionCondition", Map.of( + "type", "string", "description", "Subscribe condition (optional). The condition description to subscribe to."), + "notificationDataFormat", Map.of( + "type", "string", "description", "Notification data format (required). The description of the notification data format to report.")), + "required", List.of("topic", "notificationDataFormat")); + +// promptText is the notification subscription prompt message text generated by the client +FilledParamData result = server.validateNotificationPromptAndDataFilling( + promptText, validationSchema, templateUri); +``` + +**Output** + +On success, returns `FilledParamData` (structure same as [1.3.12](#1312-validatetaskpromptanddatafilling)); `data()` holds the parameters extracted per the schema (subscription topic, subscribe condition, notification data format, etc.). + +On failure, throws `ContentValidationException` (structure same as 1.3.12). Error codes: + +- `negotiation.invalid_input` (the prompt is null or blank, the schema is null, or the templateUri prefix segment/version does not match this API) + +- `negotiation.semantic_rejected` (required parameter missing or invalid value; the per-slot details in `errors()` use the `content.*` codes) + +- `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) + +- `template.not_found` (validation prompt resources missing) + +- `input.text_too_long` (prompt longer than `A2AT_INPUT_TEXT_MAX_CHARS`) + +Programming errors: a null templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`; a null or blank prompt or a null schema is not a programming error and throws `ContentValidationException` with `negotiation.invalid_input` (see the error codes above). + +**Response Example** + +```text +result.data() = +{ + topic=Service recovery event, + subscriptionCondition=Subnetwork name: xx subnetwork, + notificationDataFormat=Service recovery event data includes: 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. +} +``` + +### 1.3.16 generateAuthPromptFromText + +**API Definition** + +```java +public MetadataContent generateAuthPromptFromText(String text, String templateUri) +``` + +**Typical scenarios**: the client agent converts a natural-language authorization request (add/modify/delete/query network operation authorization policies) into an Authorization-T message. + +**Function Description**: generates an authorization policy operation prompt message from natural-language text with the specified Authorization-T template. One LLM slot-extraction step runs, then the template is rendered deterministically. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| text | String | Yes | Natural-language authorization description (operation type + network operation authorization policy content); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | +| templateUri | String | Yes | Authorization-T template: `StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI` (`Authorization-T/authorization-policy-management/v1`) | + +**Request Example** + +```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( + "Add an authorization for the campus private network, use service recovery for handling, do a tunnel optimization, and leave the validity period to be filled in later", + StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI); +``` + +**Output** + +On success, returns `MetadataContent` (structure same as [1.3.10](#1310-generatetaskpromptfromtext); `extensionUri` is `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`). + +On failure, throws `PromptGenerationException` (structure same as 1.3.10); for example, an operation type outside the "add/modify/delete/query authorization policy" range is rejected with `slot.constraint_violated`. Programming errors: a null text or templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`. + +**Response Example** + +```text +templateUri : Authorization-T/authorization-policy-management/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1 +promptText : +## Authorization Policy Operation Type +add authorization policy + +## Authorization Policy Operation Description +Please complete the corresponding authorization operation based on and , and return the authorization policy operation execution result in the structure defined in . indicates the expected return content. + +## Network Operation Authorization Policy List +campus private network, service recovery, tunnel optimization + +## Expected Output +1. Authorization operation execution result. Allowed values: success, failure, partial success +2. When the authorization operation is executed successfully, return the that was executed successfully +3. When the authorization operation fails or is partially successful, return a failure list containing the authorization policies and the failure reasons +``` + +### 1.3.17 generateAuthPromptFromDataWithSchema + +**API Definition** + +```java +public MetadataContent generateAuthPromptFromDataWithSchema( + Map data, Map schema, String templateUri) +``` + +**Typical scenarios**: the client agent converts structured authorization policy data submitted field by field by an authorization management UI or system into an Authorization-T message. + +**Function Description**: generates an authorization policy operation prompt from structured data plus a semantic schema with the specified Authorization-T template, skipping scenario recognition. The semantic constraints are the same as 1.3.11. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| data | Map<String, Object> | Yes | Structured authorization input (operation type, policy count, policy detail list, etc.) | +| schema | Map<String, Object> | Yes (non-empty) | Field-semantics JSON Schema | +| templateUri | String | Yes | Authorization-T template | + +**Request Example** + +```java +import java.util.List; +import java.util.Map; + +Map data = Map.of( + "operationType", "add authorization policy", + "policyCount", 2, + "details", List.of( + Map.of("businessScenario", "campus private network", "handlingType", "service recovery", + "operationName", "tunnel optimization", "validityPeriod", "permanently valid"), + Map.of("businessScenario", "medical dedicated line", "handlingType", "service restoration", + "operationName", "frequency band adjustment", "validityPeriod", "2026-06-01~2030-06-18"))); + +Map schema = Map.of( + "type", "object", + "properties", Map.of( + "operationType", Map.of( + "type", "string", + "enum", List.of("add authorization policy", "modify authorization policy", "delete authorization policy", "query authorization policy")), + "policyCount", Map.of("type", "integer", "description", "Number of policies to add"), + "details", Map.of( + "type", "array", + "description", "Policy detail list", + "items", Map.of( + "type", "object", + "properties", Map.of( + "businessScenario", Map.of("type", "string"), + "handlingType", Map.of("type", "string"), + "operationName", Map.of("type", "string"), + "validityPeriod", Map.of("type", "string")))))); + +MetadataContent result = client.generateAuthPromptFromDataWithSchema( + data, schema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI); +``` + +**Output** + +On success, returns `MetadataContent` (structure same as [1.3.10](#1310-generatetaskpromptfromtext); `extensionUri` is `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`). + +On failure, throws `PromptGenerationException` (structure same as 1.3.10). Programming errors: a null argument throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) or an empty schema map throws `IllegalArgumentException`. + +**Response Example** + +```text +templateUri : Authorization-T/authorization-policy-management/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1 +promptText : +## Authorization Policy Operation Type +add authorization policy + +## Authorization Policy Operation Description +Please complete the corresponding authorization operation based on and , and return the authorization policy operation execution result in the structure defined in . indicates the expected return content. + +## Network Operation Authorization Policy List +campus private network, service recovery, tunnel optimization, permanently valid; medical dedicated line, service restoration, frequency band adjustment, 2026-06-01~2030-06-18 + +## Expected Output +1. Authorization operation execution result. Allowed values: success, failure, partial success +2. When the authorization operation is executed successfully, return the that was executed successfully +3. When the authorization operation fails or is partially successful, return a failure list containing the authorization policies and the failure reasons +``` + +### 1.3.18 validateAuthPromptAndDataFilling + +**API Definition** + +```java +public FilledParamData validateAuthPromptAndDataFilling( + String prompt, Map schema, String templateUri) +``` + +**Typical scenarios**: the server agent validates a received Authorization-T message, extracts the operation type and policy list, and performs the authorization policy management action accordingly. + +**Function Description**: validates whether an Authorization-T authorization prompt message matches the template and slot constraints, and extracts parameters per the caller's schema (operation type, policy list, etc.). Field requirements are validated differently per operation type: an add entry must carry business scenario / handling type / operation name / validity period; a modify entry must carry the policy identifier and the new validity period; a delete entry may be a policy identifier or condition fields. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| prompt | String | Yes (non-blank) | Authorization prompt message text to validate; the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | +| schema | Map<String, Object> | Yes | Caller-provided parameter JSON Schema (containing `operationType`, `policyList`, etc.) | +| templateUri | String | Yes | Authorization-T template (the prefix segment must be `Authorization-T`) | + +**Request Example** + +```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 declares: operationType (enum: add/modify/delete/query authorization policy), policyList +// (array whose entries carry policyId/businessScenario/handlingType/operationName/validityPeriod) + +FilledParamData result = server.validateAuthPromptAndDataFilling( + metadata.promptText(), paramSchema, StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI); +``` + +**Output** + +On success, returns `FilledParamData` (structure same as [1.3.12](#1312-validatetaskpromptanddatafilling)); `data()` holds the parameters extracted per the schema (operation type, policy list, etc.). + +On failure, throws `ContentValidationException` (structure same as 1.3.12). Error codes: + +- `negotiation.invalid_input` (the prompt is null or blank, the schema is null, or the templateUri prefix segment/version does not match this API) + +- `negotiation.semantic_rejected` (required parameter missing or invalid value, e.g. an add entry missing required fields (`content.entry_field_missing`) or a validity period format error (`content.format_error`)) + +- `llm.invocation_failed` / `llm.response_invalid` (LLM failures, retryable) + +- `template.not_found` (validation prompt resources missing) + +- `input.text_too_long` (prompt longer than `A2AT_INPUT_TEXT_MAX_CHARS`) + +Programming errors: a null templateUri throws `NullPointerException`; a blank or malformed templateUri (see the facade failure policy in 1.1) throws `IllegalArgumentException`; a null or blank prompt or a null schema is not a programming error and throws `ContentValidationException` with `negotiation.invalid_input` (see the error codes above). + +**Response Example** + +```text +result.data() = +{ + operationType=add authorization policy, + policyList=[ + {policyId=null, businessScenario=campus private network, handlingType=service recovery, operationName=tunnel optimization, validityPeriod=permanently valid}, + {policyId=null, businessScenario=medical dedicated line, handlingType=service restoration, operationName=frequency band adjustment, validityPeriod=2026-06-01~2030-06-18} + ] +} +``` + +### 1.3.19 generateTaskPrompt + +**API Definition** + +```java +public PromptGenerationResult generateTaskPrompt(Object userInput) +``` + +**Typical scenarios**: the scenario-auto-routing entry point of the client agent: without specifying a template, the SDK recognizes the business scenario of the user input and generates the corresponding message; suitable when the scenario set is known and a simplified integration is desired. + +**Function Description**: the general task prompt generation entry point, which locates the template via scenario recognition: the natural-language or structured input first goes through LLM scenario recognition, then slot extraction and rendering are completed with the built-in template of the recognized scenario. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| userInput | Object | Yes | Task description: a `String` (natural language) or a `Map` (structured input); both go through LLM extraction. When the input is a `String`, its length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | + +**Request Example** + +```java +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()) { + String processedPrompt = result.promptText(); // sent as A2A message metadata +} else { + System.out.println(result.failure().code() + ": " + result.failure().message()); +} +``` + +**Output** + +On success (`success()` is `true`; no exception is thrown, the result is returned in the record): + +| Field/Method | Type | Description | +| --------- | ---- | ---- | +| success() | boolean | Always true | +| promptText() | String | Rendered task prompt message text, sent as A2A message metadata | +| failure() | PromptGenerationFailure | Always null | + +On failure (`success()` is `false`; no exception is thrown, the failure payload is returned in the record): + +| Field/Method | Type | Description | +| --------- | ---- | ---- | +| success() | boolean | Always false | +| promptText() | String | Always null | +| failure() | PromptGenerationFailure | Standardized failure payload; structure in the table below | + +`PromptGenerationFailure` structure: + +| Field | Type | Description | +| ---- | ---- | ---- | +| code | String | Machine-readable error code from the error-code list, e.g. `scenario.not_matched` (scenario recognition missed), `input.text_too_long` (input length guard), `template.load_failed` (prompt resource load failure), `template.not_found` (template missing), `slot.schema_not_found` (slot schema missing), `slot.not_provided` (required slot missing), `template.render_failed` (rendering failure), `llm.invocation_failed` / `llm.response_invalid` (LLM failures) | +| message | String | Human-readable failure description | +| stage | String | Stage where the failure occurred: `scenario` (scenario recognition), `generation` (template loading/rendering) | + +**Response Example** + +On success: + +```text +result.success() = true +result.promptText() = +## 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) +``` + +On failure (the input does not match any built-in scenario): + +```text +result.success() = false +result.failure() = +{ code=scenario.not_matched, message=The input does not match any known scenario: , stage=scenario } +``` + +### 1.3.20 checkTaskPrompt + +**API Definition** + +```java +public PromptComplianceResult checkTaskPrompt(String processedPromptText) +``` + +**Typical scenarios**: the server agent performs a protocol-completeness validation (scenario/template/slot compliance) on a received task message, in scenarios where only a pass/fail conclusion is needed and no parameters must be extracted. + +**Function Description**: the general task prompt compliance-check entry point (server side): it performs scenario matching, template compliance validation, and slot validation on the processed task prompt sent by the client. Difference from `validateTaskPromptAndDataFilling`: this API extracts no parameters and takes no caller-provided schema; it returns only the standardized pass/fail conclusion. + +**Input** + +| Parameter | Type | Required | Description | +| ---- | ---- | ---- | ---- | +| processedPromptText | String | Yes | A2A-T protocol message text sent by the client (the value keyed by the extension URI in `Message.metadata`); the input length is limited by the `A2AT_INPUT_TEXT_MAX_CHARS` configuration item, default 16384 | + +**Request Example** + +```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()); +} +``` + +**Output** + +On success (`success()` is `true`; no exception is thrown, the result is returned in the record): + +| Field/Method | Type | Description | +| --------- | ---- | ---- | +| success() | boolean | Always true | +| failure() | PromptComplianceFailure | Always null | + +On failure (`success()` is `false`; no exception is thrown, the failure payload is returned in the record): + +| Field/Method | Type | Description | +| --------- | ---- | ---- | +| success() | boolean | Always false | +| failure() | PromptComplianceFailure | Standardized failure payload; structure in the table below | + +`PromptComplianceFailure` structure: + +| Field | Type | Description | +| ---- | ---- | ---- | +| code | String | Machine-readable error code from the error-code list: `scenario.not_matched` (message parsing/scenario recognition failure), `template.not_found` (template missing), slot-domain codes such as `slot.not_provided` (missing required value), `slot.constraint_violated` (out-of-range value) and `slot.rule_violation` (other slot rule violations), `input.text_too_long` (input length guard) | +| message | String | Human-readable failure description | +| stage | String | Stage where the failure occurred: `input_gate` (input length guard), `prompt_parse` (message parsing), `generation` (template loading), `slot_validation` (slot validation) | + +**Response Example** + +On success: + +```text +result.success() = true +``` + +On failure: + +```text +result.success() = false +result.failure() = +{ code=slot.not_provided, message='Task Object' is not provided in the input., stage=slot_validation } +``` + +## 1.4 Error Code List + +**Error code categories**: BUSINESS = expected business failures the caller can act on, carried by `A2ATBusinessException` subclasses; INFRA = infrastructure failures, carried by a plain `A2ATError`. + +| Code | Category | Message (en-US) | Message (zh-CN) | +| ---- | -------- | ---------------- | --------------- | +| `template.not_found` | BUSINESS | Template '{template_uri}' does not support language '{language}'; check the template URI and language setting | 模板「{template_uri}」不支持语言「{language}」,请检查模板标识与语言配置 | +| `template.render_failed` | BUSINESS | Failed to render template '{template_uri}': {reason} | 模板「{template_uri}」渲染失败:{reason} | +| `template.load_failed` | INFRA | Failed to read template resource '{resource_path}' | 模板资源「{resource_path}」读取失败 | +| `slot.schema_not_found` | BUSINESS | Template '{template_uri}' is missing its slot schema (language '{language}') | 模板「{template_uri}」缺少参数定义文件(语言「{language}」) | +| `slot.not_provided` | BUSINESS | '{slot_label}' is not provided in the input. | 输入中未提供「{slot_label}」。 | +| `slot.constraint_violated` | BUSINESS | The value of '{slot_label}' ({actual}) is not within the allowed range | 「{slot_label}」的取值「{actual}」不在允许范围内 | +| `slot.semantic_conflict` | BUSINESS | The value of '{slot_label}' conflicts with the slot definition: {reason} | 「{slot_label}」的取值与参数定义冲突:{reason} | +| `slot.fabricated_value` | BUSINESS | The value of '{slot_label}' ({actual}) is placeholder content, not a valid value | 「{slot_label}」的取值「{actual}」是占位内容,不是有效值 | +| `slot.cross_scenario_pollution` | BUSINESS | The value of '{slot_label}' contains content from a different scenario | 「{slot_label}」的取值混入了其他场景的内容 | +| `slot.insufficient_grounding` | BUSINESS | The value of '{slot_label}' lacks sufficient grounding | 「{slot_label}」的取值缺少充分依据 | +| `slot.rule_violation` | BUSINESS | The value of '{slot_label}' violates the validation rules. | 「{slot_label}」的取值不符合校验规则。 | +| `input.text_too_long` | BUSINESS | Input text length {actual_length} exceeds the maximum of {max_chars} (A2AT_INPUT_TEXT_MAX_CHARS) | 输入文本长度 {actual_length} 超过上限 {max_chars}(A2AT_INPUT_TEXT_MAX_CHARS) | +| `content.param_missing` | BUSINESS | '{section_label}' is empty; please provide a value | 「{section_label}」未填写,请补充该参数的取值 | +| `content.entry_field_missing` | BUSINESS | Entry {index} of '{section_label}' is missing required field '{field_label}' | 「{section_label}」第 {index} 条缺少必填字段「{field_label}」 | +| `content.format_error` | BUSINESS | The format of '{section_label}' is invalid: {reason} | 「{section_label}」的取值格式不符合要求:{reason} | +| `content.value_not_allowed` | BUSINESS | The value of '{section_label}' ({actual}) is not allowed | 「{section_label}」的取值「{actual}」不在允许范围内 | +| `content.semantic_conflict` | BUSINESS | '{section_label}' has a semantic conflict: {reason} | 「{section_label}」存在语义冲突:{reason} | +| `content.rule_violation` | BUSINESS | The value of '{section_label}' violates the validation rules. | 「{section_label}」的取值不符合校验规则。 | +| `scenario.not_matched` | BUSINESS | The input does not match any known scenario: {reason} | 输入内容无法匹配任何已知场景:{reason} | +| `llm.not_configured` | BUSINESS | No LLM client is configured; check the A2AT_LLM_* settings | 未配置 LLM 客户端,无法执行该操作(请检查 A2AT_LLM_* 配置) | +| `llm.invocation_failed` | BUSINESS | LLM invocation failed (provider {provider}): {reason} | LLM 调用失败(提供方 {provider}):{reason} | +| `llm.response_invalid` | BUSINESS | The LLM response is invalid (step: {step}); please retry | LLM 返回内容不符合要求({step} 步骤),请重试 | +| `negotiation.invalid_input` | BUSINESS | The negotiation input is invalid: {reason} | 输入的协商内容无效:{reason} | +| `negotiation.invalid_context_id` | BUSINESS | The negotiation context id '{actual}' is not a valid UUID | 协商上下文标识「{actual}」不是合法的 UUID | +| `negotiation.round_exceeded` | BUSINESS | Negotiation round {round} exceeds the maximum of {max_rounds} | 协商轮次 {round} 已超过上限 {max_rounds} | +| `negotiation.type_mismatch` | BUSINESS | The message implies '{implied}' negotiation but the declared template type is '{declared}' | 报文内容属于「{implied}」协商,与声明的模板类型「{declared}」不符 | +| `negotiation.phase_mismatch` | BUSINESS | The message phase does not match the declared template phase ({implied} vs {declared}) | 报文阶段与声明的模板阶段不符({implied} vs {declared}) | +| `negotiation.conclusion_mismatch` | BUSINESS | The message conclusion is '{actual}' but '{expected}' is expected for this method | 报文结论为「{actual}」,与该方法的预期「{expected}」不符 | +| `negotiation.content_invalid` | BUSINESS | The negotiation content field '{field}' is invalid: {reason} | 协商内容字段「{field}」无效:{reason} | +| `negotiation.field_missing` | BUSINESS | The negotiation message is missing required field '{field}' | 协商报文缺少必填字段「{field}」 | +| `negotiation.content_extract_failed` | BUSINESS | Failed to extract negotiation content from text ({field}): {reason} | 无法从文本提取协商内容({field}):{reason} | +| `negotiation.conclusion_content_mismatch` | BUSINESS | The conclusion is '{conclusion}' but '{section_label}' does not state the content the conclusion requires | 结论为「{conclusion}」,但「{section_label}」未表达该结论应携带的内容 | +| `negotiation.missing_result_content` | BUSINESS | The '{section_label}' section is missing the content required by its conclusion | 「{section_label}」板块缺少结论应携带的内容 | +| `negotiation.mutually_exclusive_sections` | BUSINESS | Mutually exclusive sections appear together: {sections} | 互斥板块同时出现:{sections} | +| `negotiation.constraint_conflict` | BUSINESS | '{section_label}' conflicts with existing constraints: {reason} | 「{section_label}」与既有约束冲突:{reason} | +| `negotiation.field_inconsistency` | BUSINESS | Fields within '{section_label}' are inconsistent: {reason} | 「{section_label}」内字段取值前后不一致:{reason} | +| `negotiation.invalid_time_interval` | BUSINESS | The time interval of '{section_label}' is invalid (start must not be later than end) | 「{section_label}」的时间区间不合法(开始时间不得晚于结束时间) | +| `negotiation.semantic_rejected` | BUSINESS | The negotiation message failed semantic validation | 协商报文语义校验未通过 | +| `negotiation.rule_violation` | BUSINESS | '{section_label}' violates the negotiation message validation rules. | 「{section_label}」不符合协商报文的校验规则。 | +| `infra.config_invalid` | INFRA | Invalid configuration '{key}': {reason} | 配置项「{key}」无效:{reason} | +| `infra.resource_read_failed` | INFRA | Failed to read resource '{resource_path}' | 资源「{resource_path}」读取失败 | +| `infra.internal_error` | INFRA | SDK internal error; contact the maintainer with context | SDK 内部错误,请联系维护方并提供上下文 | + + + + + diff --git a/docs/en/developer_guide.md b/docs/en/developer_guide.md index 8dda2472..52feadac 100644 --- a/docs/en/developer_guide.md +++ b/docs/en/developer_guide.md @@ -1,55 +1,291 @@ # 1 a2a-t-sdk-java Developer Guide +| Category | Description | +| -------- | ----------- | +| Target readers | Developers, integration and deployment engineers, and project delivery O&M personnel who build multi-agent protocol interactions on the A2A-T SDK | +| Document purpose | This document describes the complete installation, parameter configuration, and minimal practice of the A2A-T SDK. It helps users complete SDK integration, feature development, and production rollout quickly and to standard. | +| Prerequisites | Understand the data model definitions and usage of the A2A multi-agent protocol, understand the AgentCard model definition and usage, and understand registry center related functions | + ## 1.1 Feature Introduction -See the [User Guide 1.1 Feature Introduction](https://github.com/project-openan/a2a-t-sdk-java/blob/main/docs/en/user_guide.md#11-feature-introduction) section. +### 1.1.1 A2A-T Capabilities +A2A-T (Agent-to-Agent Telecom) is a telecom-domain multi-agent interconnection protocol built on the A2A protocol, designed for complex collaboration scenarios in the telecom domain. + +Business scenarios in the telecom domain are complex and demanding; the interconnection and collaboration of O&M agents require dedicated protocol support. Building on the A2A protocol, the A2A-T solution applies extensions focused on the information models, task negotiation, and collaboration security related to telecom-domain service flows. + +a2a-t-sdk-java is a Java SDK for telecom agent collaboration scenarios. It generates, validates, and negotiates task prompts in A2A-T interactions. The SDK is suitable for integration by client agents, server agents, and upper-layer orchestration systems. + +Main capabilities include: + +- **Client-side template message generation**: the client generates protocol messages conforming to the A2A-T format from natural-language or structured input. +- **Server-side template message validation**: the server validates whether A2A-T protocol-format messages sent by the client match the scenario, template, and slot constraints. +- **Multi-round negotiation management**: supports the `information`, `feasibility`, and `target` negotiation flows. +- **Template resource management**: bundles scenario, slot, template, and system prompt resources, and supports two resource loading modes: `classpath` built-in resources and `local_file` local file resources. +- **LLM adaptation**: connects to external large models through OpenAI-compatible call chains. + +For the API list and usage details provided by the SDK, see [API_Reference.md](API_Reference.md). + +### 1.1.2 Relationship Between the A2A-T SDK and the A2A SDK + +The A2A-T protocol is an extension of the A2A protocol. The A2A-T SDK is provided for the extended protocol content, supporting rapid construction of agents for complex collaboration scenarios in the telecom domain. + +The A2A-T SDK is independent of the A2A SDK. By integrating both the A2A-T SDK and the A2A SDK, you can build agents that support the A2A-T protocol and achieve deterministic, highly reliable, efficient, and secure collaboration among multiple agents in the telecom domain. The A2A SDK to pair with in the Java ecosystem is a2a-java (`org.a2aproject.sdk`). + +```mermaid +flowchart LR + subgraph Server["Server Agent"] + B0["Server agent business code"] + B1["A2A Server SDK

      1. Receive requests
      2. Task management and event queue management
      ..."] + B2["A2A-T Server SDK

      1. Template compliance validation and parameter extraction
      2. Negotiation state management
      ..."] + B0 --receive responses--- B1 + B0 --A2A-T message validation and parameter extraction--- B2 + end + + subgraph Client["Client Agent"] + A0["Client agent business code"] + A1["A2A-T Client SDK

      1. Provide A2A-T prompt templates
      2. Generate A2A-T protocol template messages
      3. Negotiation state management
      ..."] + A2["A2A Client SDK

      1. Provide the A2A standard data object model
      2. Task management and event queue management
      3. Send requests, multi-protocol support: JSON-RPC, HTTP/REST
      ..."] + A0 --generate A2A-T protocol messages--- A1 + A0 --assemble A2A extensions and send requests--- A2 + end + + Client -- HTTPS A2A-T requests --> Server + Server -- HTTPS A2A-T responses --> Client + +``` + +### 1.1.3 Typical Interaction Scenarios of the A2A-T SDK + +A typical multi-agent collaboration interaction scenario involves at least three components: the client agent, the server agent, and the registry center. + +The relationships between the components are shown below: + +```mermaid +flowchart TD + subgraph Client["Client Agent"] + A1["A2A-T Client SDK

      Prompt template generation: generate*PromptFromText"] + A2["A2A Client SDK

      Sends requests over HTTP/REST"] + A1 --> A2 + end + + subgraph Server["Server Agent"] + B1["A2A-T Server SDK

      Compliance validation and parameter extraction: validate*AndDataFilling"] + B2["A2A Server SDK

      Receives requests"] + B2 --> B1 + end + + Registry["Registry Center (registry-center)"] + + A2 -- HTTPS --> B2 + Client -. Register / Discover .-> Registry + Server -. Register / Discover .-> Registry +``` + + ## 1.2 Constraints and Limitations -1. JDK requirement is 17+. -2. Currently supports `classpath` and `local_file` prompt resources. The default source is the built-in resources packaged in the `a2a-t-resources` jar. -3. By default supports `local_rule` and `openai_compatible` LLM provider. -4. Negotiation state storage currently supports `in_memory`. -5. The SDK is not responsible for starting business HTTP services, user authentication, key management, or registry center deployment. +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); in `local_file` mode business content is local-first and falls back to the built-in copy when missing (overlay). +4. Negotiation state storage provides only `in_memory`; the state is lost when the process exits. +5. The A2A-T SDK does not provide an agent HTTP service framework, a registry center client, authentication, or key management capabilities; these must be integrated by the business system. -## 1.3 Environment Setup +## 1.3 Environment Preparation -### 1.3.1 Get Source Code +### 1.3.1 Environment Requirements -```bash -git clone git@github.com:project-openan/a2a-t-sdk-java.git -cd a2a-t-sdk-java +| 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 | + +### 1.3.2 Setting Up the Environment + +Taking a Windows 11 64-bit amd64 development environment as an example + +**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 + +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 + +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. A2A-T Client SDK** + +API definition and function description: generates a notification subscription prompt message from natural-language text with the specified Notification-T template (skipping scenario recognition). It runs one LLM slot-extraction step and then renders the template deterministically; the generation phase performs built-in slot schema validation. + +```java +public MetadataContent generateNotificationPromptFromText(String text, String templateUri) ``` -### 1.3.2 Build Project +Sample invocation: -```bash -mvn -DskipTests package +```java +import java.nio.file.Path; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; + +A2ATClient client = new A2ATClient(Path.of("client.env")); + +MetadataContent result = client.generateNotificationPromptFromText( + "I would like to subscribe to service recovery events. The notification data format is as follows: " + + "1. Service recovery plan execution status, possible values: not started, finished; " + + "2. Complaint diagnosis task serial number; 3. OSS-side event serial number; 4. Access port name; " + + "5. Whether OMC automatic recovery is authorized, possible values: yes, no; 6. Service recovery plan name; 7. Service recovery plan details", + "Notification-T/network-layer/service-recovery/v1"); + +/* Output of the generated notification subscription prompt message (result.promptText(); the actual text varies with the LLM slot-extraction result; this example provides no subscribe condition, so the corresponding section is left empty) +## Subscription Description +Please complete the network-side service recovery event subscription and reporting task based on the following , , , and information. + +## Notification Topic +Service recovery event + +## Subscribe Condition + +## Notification Data Format +1. Service recovery plan execution status, possible values: not started, finished +2. Complaint diagnosis task serial number +3. OSS-side event serial number +4. Access port name +5. Whether OMC automatic recovery is authorized, possible values: yes, no +6. Service recovery plan name +7. Service recovery plan details + +## Expected Output +1. Subscription result, possible values: success +2. After a successful subscription, report messages per the +*/ ``` -### 1.3.3 Run Tests +**2. A2A-T Server SDK** -```bash -mvn test +API definition and function description: validates whether a Notification-T notification subscription prompt message matches the template and slot constraints, and extracts parameters (subscription topic, subscription condition, notification data format, etc.) per the caller's schema. + +```java +public FilledParamData validateNotificationPromptAndDataFilling( + String prompt, Map schema, String templateUri) ``` -### 1.3.4 Format Check +Sample invocation: validates the notification subscription message sent by the client and extracts the subscription parameters -The project uses Spotless to manage Java formatting: +```java +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.server.A2ATServer; -```bash -mvn spotless:check +A2ATServer server = new A2ATServer(Path.of("server.env")); + +Map validationSchema = Map.of( + "type", "object", + "properties", Map.of( + "topic", Map.of("type", "string", "description", "Subscription topic (required). The name of the event topic to subscribe to."), + "subscriptionCondition", Map.of( + "type", "string", "description", "Subscription condition (optional). The description of the condition to subscribe to."), + "notificationDataFormat", Map.of( + "type", "string", "description", "Notification data format (required). The description of the notification data format to report.")), + "required", List.of("topic", "notificationDataFormat")); + +// promptText is the text of the notification subscription prompt message generated by the client (see the client sample invocation above) +FilledParamData result = server.validateNotificationPromptAndDataFilling( + promptText, validationSchema, "Notification-T/network-layer/service-recovery/v1"); + +/* Subscription parameters extracted per the schema (result.data(); an empty subscription condition + means the input in this example carries no subscription condition) +{ + topic=Service recovery event, + subscriptionCondition=, + notificationDataFormat=Service recovery event data includes: service recovery plan execution status (not started, finished), complaint diagnosis task serial number, OSS-side event serial number, access port name, whether OMC automatic recovery is authorized (yes, no), service recovery plan name, service recovery plan details. +} +*/ ``` -For auto-formatting: +### 1.4.2 Development Workflow -```bash -mvn spotless:apply +- Client development workflow + +```mermaid +flowchart TD + Step1["Step1 Install dependencies"] --> + Step2["Step2 Configure the LLM"] + + Step2 -- Optional for secondary development --> Step3 + + Step3["Step3 Initialize the AgentCard"] --> + Step4["Step4 Register and discover the AgentCard"] + + Step2 --> Step5 + + 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"] +``` + +> Secondary development means an agent has already been developed based on A2A and integrated with a registry center. + +- Server development workflow: + +```mermaid +flowchart TD + Step1["Step1 Install dependencies"] --> + Step2["Step2 Configure the LLM"] + + Step2 -- Optional for secondary development --> Step3 + + Step3["Step3 Initialize the AgentCard"] --> + Step4["Step4 Register and discover the AgentCard"] + + Step2 --> Step5 + + 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.4 Maven Dependencies -Business projects can use BOM to manage versions: + + +### 1.4.3 Sample Client Development Steps + +#### Step1 Install Dependencies + +Business projects can use the BOM to manage A2A-T SDK versions uniformly: ```xml @@ -57,7 +293,7 @@ Business projects can use BOM to manage versions: net.openan.a2a-t.sdk a2a-t-bom - 1.0.0 + 1.1.1 pom import @@ -65,36 +301,380 @@ Business projects can use BOM to manage versions: ``` -Client Agent dependency: +Client agent dependencies: ```xml + net.openan.a2a-t.sdk a2a-t-client -``` -Server Agent dependency: - -```xml + - net.openan.a2a-t.sdk - a2a-t-server + org.a2aproject.sdk + a2a-java-sdk-client + 1.0.0.Beta1 + + + org.a2aproject.sdk + a2a-java-sdk-client-transport-rest + 1.0.0.Beta1 ``` -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: +> 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 +> +> ``` -```xml - - net.openan.a2a-t.sdk - a2a-t-resources - +#### Step2 Configure the LLM + +Copy the content of the `env.example` file in the repository root into `client.env` (`server.env` on the server side) and configure it as follows: + +```properties +# Controls the language used by prompt runtime components. +A2AT_LANGUAGE=en-US + +# Selects the prompt source backend (classpath | local_file). +A2AT_PROMPT_SOURCE_TYPE=classpath + +# Required when A2AT_PROMPT_SOURCE_TYPE=local_file +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + +# Selects the LLM provider used by the shared client. +A2AT_LLM_PROVIDER=openai + +# Selects the default model name for the configured provider. +A2AT_LLM_MODEL=deepseek-chat + +# API key for the configured LLM provider. +A2AT_LLM_API_KEY={your_llm_api_key} + +# Base URL for the configured LLM provider. +A2AT_LLM_BASE_URL=https://api.deepseek.com + +# Selects the negotiation state store implementation. +A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + +# Input limits +A2AT_INPUT_TEXT_MAX_CHARS=16384 ``` -## 1.5 Configuration Loading +> `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`. + +#### Step3 Initialize the AgentCard -The Java SDK does not auto-discover `.env`; the caller must explicitly pass the path: +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": [...]}`). + +- Reference client AgentCard definition (you can declare the supported A2A-T templates in `extensions`): + +```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_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; +private static final String NOTIFICATION_T_EXT_URI = + "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_URI) + .description("Extension of structured prompt Task-T requests.") + .build(), + AgentExtension.builder() + .uri(NOTIFICATION_T_EXT_URI) + .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(); +} +``` + +- 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" + ] + } + ] + } + ] +} +``` + +#### Step4 Register and Discover the AgentCard + +- **AgentCard registration**: publish the client AgentCard to the registry center. The address and URI of the registry center depend on the actual deployment + +```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 discovery**: query the registry center by the target agent name or skill to obtain its AgentCard, and thereby the `url` and supported skills + +```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); +} +``` + +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: + +```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); +} + +private static List stringList(Object value) { + return value instanceof List values ? values.stream().map(String::valueOf).toList() : List.of(); +} +``` + +#### Step5 Initialize the A2A-T Client ```java import java.nio.file.Path; @@ -103,300 +683,1123 @@ import net.openan.a2at.sdk.client.A2ATClient; A2ATClient client = new A2ATClient(Path.of("client.env")); ``` -Basic configuration example: +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. -```properties -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=classpath -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible -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 +#### Step6 Generate the A2A-T Template Message + +The client uses the A2A-T Client SDK API `generateNotificationPromptFromText` to generate the Notification-T subscription message (processed_prompt), and then sends it to the target agent as part of an A2A message. + +```java +import java.nio.file.Path; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; + +A2ATClient client = new A2ATClient(Path.of("client.env")); + +// Generate the A2A-T notification subscription prompt message +// (on generation failure a PromptGenerationException is thrown, carrying code and message) +MetadataContent result = client.generateNotificationPromptFromText( + "I would like to subscribe to service recovery events. The notification data format is as follows: " + + "1. Service recovery plan execution status, possible values: not started, finished; " + + "2. Complaint diagnosis task serial number; 3. OSS-side event serial number; 4. Access port name; " + + "5. Whether OMC automatic recovery is authorized, possible values: yes, no; 6. Service recovery plan name; 7. Service recovery plan details", + "Notification-T/network-layer/service-recovery/v1"); + +String processedPrompt = result.promptText(); +String extensionUri = result.extensionUri(); // A2A-T extension URI, used for message metadata and request headers ``` +#### Step7 Activate the A2A-T Extension in Request Headers + +The A2A protocol conveys the protocol version and extension declarations through HTTP headers. The following headers are required: + +| 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 | Required when using A2A-T | Comma-separated list of extension URIs, declaring the extensions used by this request | + +Sample of filling in client request headers: when sending requests with a2a-java, `A2A-Extensions` is passed in through the headers of `ClientCallContext` (protocol headers such as the protocol version are carried by the a2a-java transport layer as required by the protocol) + +```java +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"; + +ClientCallContext callContext = + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", notificationPromptExt)); +``` -## 1.6 SDK Basic Usage +#### Step8 Send the Request with the A2A-T Extension -### 1.6.1 Client Generates Task Prompt +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. ```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 net.openan.a2at.sdk.core.model.MetadataContent; +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 template message (see 1.4.3 Step6) 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"); +MetadataContent result = client.generateNotificationPromptFromText( + "I would like to subscribe to service recovery events. The notification data format is as follows: " + + "1. Service recovery plan execution status, possible values: not started, finished; " + + "2. Complaint diagnosis task serial number; 3. OSS-side event serial number; 4. Access port name; " + + "5. Whether OMC automatic recovery is authorized, possible values: yes, no; 6. Service recovery plan name; 7. Service recovery plan details", + "Notification-T/network-layer/service-recovery/v1"); +String processedPrompt = result.promptText(); + +// 2) Build the A2A message carrying the A2A-T extension (the prompt goes into metadata, keyed by the extension URI) +Message message = Message.builder() + .messageId(UUID.randomUUID().toString()) + .role(Message.Role.ROLE_USER) + .parts(new TextPart("I would like to subscribe to service recovery events")) + .metadata(Map.of(notificationPromptExt, processedPrompt)) + .build(); +MessageSendParams params = MessageSendParams.builder().message(message).build(); + +// 3) Declare the A2A-T extension request header through ClientCallContext (see 1.4.3 Step7) +ClientCallContext callContext = + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", notificationPromptExt)); + +// 4) Send the request with the a2a-java Client and consume task events +CountDownLatch done = new CountDownLatch(1); +try (Client a2aClient = Client.builder(agentCard) + .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(); +} -if (result.success()) { - System.out.println(result.promptText()); -} else { - System.out.println(result.failure().message()); +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.6.2 Server Validates Task Prompt +#### Complete Sample Client Code + +```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.core.model.MetadataContent; +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_T_EXT_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final ObjectMapper MAPPER = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); + 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 1.4.3 Step3 + AgentCard serverAgentCard = toAgentCard( + discoverAgent("http://{ip:port}", "Huawei", "RAN Domain Agent")); + + // 2) Use the SDK capability to generate the A2A-T notification subscription prompt message + A2ATClient client = new A2ATClient(Path.of("client.env")); + MetadataContent result = client.generateNotificationPromptFromText( + "I would like to subscribe to service recovery events. The notification data format is as follows: " + + "1. Service recovery plan execution status, possible values: not started, finished; " + + "2. Complaint diagnosis task serial number; 3. OSS-side event serial number; 4. Access port name; " + + "5. Whether OMC automatic recovery is authorized, possible values: yes, no; 6. Service recovery plan name; 7. Service recovery plan details", + "Notification-T/network-layer/service-recovery/v1"); + String processedPrompt = result.promptText(); + + // 3) Build the A2A message carrying the A2A-T extension (request headers declare the extension; the message body carries the A2A-T extension field) + Message message = Message.builder() + .messageId(UUID.randomUUID().toString()) + .role(Message.Role.ROLE_USER) + .parts(new TextPart("I would like to subscribe to service recovery events")) + .metadata(Map.of(NOTIFICATION_T_EXT_URI, processedPrompt)) + .build(); + MessageSendParams params = MessageSendParams.builder().message(message).build(); + ClientCallContext callContext = + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", NOTIFICATION_T_EXT_URI)); + + // 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); + } +} +``` + +> `buildClientAgentCard()`, `toAgentCard(Map)`, and the `string`/`stringList` helper methods are defined in Step3 and Step4. + +### 1.4.4 Sample Server Development Steps + +#### Step1-Step5 Prerequisites + +Steps such as installing dependencies and configuring the LLM can all reference the [client implementation](#143-sample-client-development-steps). The differences are as follows: + +It is recommended that the server agent use the official a2a-java REST reference server (based on Quarkus, which automatically assembles the REST transport, task management, and event queues) to host A2A requests. Add the following dependencies: + +```xml + + + net.openan.a2a-t.sdk + a2a-t-server + + + + + org.a2aproject.sdk + a2a-java-sdk-reference-rest + 1.0.0.Beta1 + + + + + com.fasterxml.jackson.core + jackson-databind + 2.20.1 + +``` + +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): + +```xml + + 3.30.6 + + + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus.platform + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + +``` + +- Initialize the A2A-T server ```java import java.nio.file.Path; 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); +``` + +- Initialize the AgentCard: a2a-java officially recommends exposing the AgentCard qualified with `@PublicAgentCard` through a CDI producer (built with the Builder; see 1.4.3 Step3 for the construction approach). Reference server AgentCard definition: -if (result.success()) { - System.out.println("prompt check passed"); -} else { - System.out.println(result.failure().message()); +```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_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; +private static final String NOTIFICATION_T_EXT_URI = + "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_URI) + .description("Extension of structured prompt TASK-T requests.") + .required(false) + .build(), + AgentExtension.builder() + .uri(NOTIFICATION_T_EXT_URI) + .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(); } ``` -### 1.6.3 Negotiation Interface +- 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 \, \, \, and \ information, complete the network-side intelligent fault Incident subscription and reporting task.\\n## Notification Topic\\nThe name of this topic is \\\"incident\\\"\\n## Subscribe Condition\\nFault level is \\\"high\\\"\\n## Notification Data Format\\nReport Incident data via DataPart\\n## Expected Output\\n1. Subscription result, success or failure\\n2. Reason for subscription failure (optional)" + ], + "inputModes": [ + "application/json", + "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" + } + ] + } + ] +} +``` + +#### Step6-Step7 Receive and Validate the Message on the Server + +When the server is hosted by a2a-java, protocol parsing, task creation, and event queue management are handled by the SDK. The business side implements the official `AgentExecutor` interface: in the `execute` callback, take the Notification-T subscription message out of the message `metadata` field (keyed by the extension URI), pass it to `A2ATServer.validateNotificationPromptAndDataFilling` for validation and subscription-parameter extraction, and push task status and result events through `AgentEmitter`: ```java +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +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; +import org.a2aproject.sdk.spec.TextPart; + +public final class ServiceRecoverySubscriptionExecutor implements AgentExecutor { + + private static final String NOTIFICATION_T_EXT_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final String NOTIFICATION_TEMPLATE_URI = + "Notification-T/network-layer/service-recovery/v1"; + + private static final Map NOTIFICATION_PARAM_SCHEMA = Map.of( + "type", "object", + "properties", Map.of( + "topic", Map.of("type", "string", "description", "Subscription topic (required). The name of the event topic to subscribe to."), + "subscriptionCondition", Map.of( + "type", "string", "description", "Subscription condition (optional). The description of the condition to subscribe to."), + "notificationDataFormat", Map.of( + "type", "string", "description", "Notification data format (required). The description of the notification data format to report.")), + "required", List.of("topic", "notificationDataFormat")); + + private final A2ATServer server; + + public ServiceRecoverySubscriptionExecutor(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 notification prompt")); + return; + } + agentEmitter.submit(statusMessage(requestContext, + "Subscription accepted, starting service recovery event reporting task")); + + // 2) Use the SDK capability to validate the Notification-T subscription message and extract the subscription parameters per the schema + Map filledParams; + try { + FilledParamData checkResult = server.validateNotificationPromptAndDataFilling( + processedPrompt, NOTIFICATION_PARAM_SCHEMA, NOTIFICATION_TEMPLATE_URI); + filledParams = checkResult.data(); + } catch (ContentValidationException error) { + // Validation failed; the exception carries code, message, and the per-slot details from errors() + agentEmitter.reject(statusMessage(requestContext, + "Notification prompt validation failed: " + error.getCode() + ": " + error.getMessage())); + return; + } + + // 3) Validation passed; establish the subscription per the extracted subscription parameters + // (topic/subscriptionCondition/notificationDataFormat) and report service recovery events + // (event fields taken from the sample a2a-t-sample/.../service-recovery/server/mock-event-data.json; + // the "service recovery plan details" field is long and omitted here) + agentEmitter.startWork(statusMessage(requestContext, + "Subscribed to [" + filledParams.get("topic") + "], service recovery event reporting in progress")); + agentEmitter.addArtifact( + List.of(new DataPart(Map.of( + "serviceManagement.ServiceRecoveryEvent", Map.of( + "service recovery plan execution status", "finished", + "complaint diagnosis task serial number", "9de168c0-6179-4778-8b72-4279582c0a3f", + "OSS-side event serial number", "event-id-202606250128", + "access port name", "P781-SZ-PTN7900-23-TPA1EG24-17", + "whether OMC automatic recovery is authorized", "yes", + "service recovery plan name", "tunnel tuning", + "service recovery plan execution end time", "2026-05-11T08:21:46Z")))), + "serviceManagement.ServiceRecoveryEvent", + "Service recovery event artifact", + null); + agentEmitter.complete(statusMessage(requestContext, + "Service recovery notification reporting finished, subscription task 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_T_EXT_URI); + 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(); + } +} +``` + +#### Step8 Fill in the A2A Response Headers and Return the Response + +| Header | Direction | Required | Value | +| ------ | --------- | -------- | ----- | +| `A2A-Extensions` | Response header | Required when using A2A-T | List of extension URIs (comma-separated) actually engaged by the server | + +When the server is hosted by the a2a-java REST transport, the protocol layer (validation of the protocol version and extension declarations in requests, task and event management, and SSE streaming responses) is handled by the SDK transport layer; the business side only needs to push task status and result events through `AgentEmitter`. If the business system implements its own HTTP hosting, it must fill in the `A2A-Extensions` header in responses as shown in the table above. + +#### Complete Sample Server Code + +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: + +```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.negotiation.types.model.NegotiationType; +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.core.model.FilledParamData; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.server.A2ATServer; +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_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; + private static final String NOTIFICATION_T_EXT_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // 1) CDI producer: expose the server AgentCard to a2a-java (same definition as the server sample AgentCard in the 1.4.4 prerequisites) + @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_URI) + .description("Extension of structured prompt TASK-T requests.") + .required(false) + .build(), + AgentExtension.builder() + .uri(NOTIFICATION_T_EXT_URI) + .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 ServiceRecoverySubscriptionExecutor(new A2ATServer(Path.of("server.env"))); + } + + // 3) Register the server AgentCard with the registry center at startup (the address depends on the actual deployment) + 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 the Notification-T subscription message, validate it, and extract parameters + // (same implementation as 1.4.4 Step6-Step7) + static final class ServiceRecoverySubscriptionExecutor implements AgentExecutor { + + private static final String NOTIFICATION_T_EXT_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final String NOTIFICATION_TEMPLATE_URI = + "Notification-T/network-layer/service-recovery/v1"; + + // Parameter extraction schema (same as a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json) + private static final Map NOTIFICATION_PARAM_SCHEMA = Map.of( + "type", "object", + "properties", Map.of( + "topic", Map.of("type", "string", "description", "Subscription topic (required). The name of the event topic to subscribe to."), + "subscriptionCondition", Map.of( + "type", "string", "description", "Subscription condition (optional). The description of the condition to subscribe to."), + "notificationDataFormat", Map.of( + "type", "string", "description", "Notification data format (required). The description of the notification data format to report.")), + "required", List.of("topic", "notificationDataFormat")); + + private final A2ATServer server; + + ServiceRecoverySubscriptionExecutor(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 notification prompt")); + return; + } + agentEmitter.submit(statusMessage(requestContext, + "Subscription accepted, starting service recovery event reporting task")); + + // 4.2) Use the SDK capability to validate the Notification-T subscription message and extract the subscription parameters per the schema + Map filledParams; + try { + FilledParamData checkResult = server.validateNotificationPromptAndDataFilling( + processedPrompt, NOTIFICATION_PARAM_SCHEMA, NOTIFICATION_TEMPLATE_URI); + filledParams = checkResult.data(); + } catch (ContentValidationException error) { + // Validation failed; the exception carries code, message, and the per-slot details from errors() + agentEmitter.reject(statusMessage(requestContext, + "Notification prompt validation failed: " + error.getCode() + ": " + error.getMessage())); + return; + } + + // 4.3) Validation passed; establish the subscription per the extracted subscription parameters + // (topic/subscriptionCondition/notificationDataFormat) and report service recovery events + // (event fields taken from the sample a2a-t-sample/.../service-recovery/server/mock-event-data.json; + // the "service recovery plan details" field is long and omitted here) + agentEmitter.startWork(statusMessage(requestContext, + "Subscribed to [" + filledParams.get("topic") + "], service recovery event reporting in progress")); + agentEmitter.addArtifact( + List.of(new DataPart(Map.of( + "serviceManagement.ServiceRecoveryEvent", Map.of( + "service recovery plan execution status", "finished", + "complaint diagnosis task serial number", "9de168c0-6179-4778-8b72-4279582c0a3f", + "OSS-side event serial number", "event-id-202606250128", + "access port name", "P781-SZ-PTN7900-23-TPA1EG24-17", + "whether OMC automatic recovery is authorized", "yes", + "service recovery plan name", "tunnel tuning", + "service recovery plan execution end time", "2026-05-11T08:21:46Z")))), + "serviceManagement.ServiceRecoveryEvent", + "Service recovery event artifact", + null); + agentEmitter.complete(statusMessage(requestContext, + "Service recovery notification reporting finished, subscription task 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_T_EXT_URI); + 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(); + } + } +} +``` -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"))); +Configure the service listening address in `src/main/resources/application.properties`: + +```properties +quarkus.http.host=127.0.0.1 +quarkus.http.port=26335 ``` -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`. +Start the server: +```bash +mvn clean package +java -jar target/quarkus-app/quarkus-run.jar +``` -## 1.7 Full Integration Development Flow +After startup, the server will: -### 1.7.1 Client Agent +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. -Recommended Client Agent flow: +> 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). -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`. +## 1.5 Loading Custom Templates -`ClientSampleFlow` in `a2a-t-sample` demonstrates the full flow: reading scenarios, querying AgentCard directly from the sample server or from registry-center, generating prompt, constructing A2A requests, and handling streaming events. +### 1.5.1 Background -### 1.7.2 Server Agent +When generating and validating A2A-T prompts, the SDK relies on three kinds of prompt resources: the scenario catalog (scenarios), slot definitions (slots), and template bodies (templates). The built-in resources are packaged in the `a2a-t-resources` jar and loaded from the classpath by default. When a business needs its own scenario templates (for example, adding business scenarios, or adjusting template wording or slot constraints), it can switch the resource source to `local_file` and load custom templates from a local directory — no SDK repackaging required. -Recommended Server Agent flow: +**The capability boundary of custom templates is as follows**: -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. +| Resource | `local_file` mode | `classpath` mode | +| --- | --- | --- | +| Business templates/slots/scenarios (templates/slots/scenarios of Task-T, Notification-T, Authorization-T) | Local-first, falls back to built-in when missing (overlay) | Built-in resources | +| Negotiation templates (fixed Negotiation-T set) and the negotiation vocabulary (negotiation-vocabulary) | Loaded from the classpath; not customizable | Built-in resources | +| LLM instruction prompts (prompts directory) | Loaded from the classpath; not customizable | Built-in resources | -`ServerSampleMain` and `ServerSampleFlow` in `a2a-t-sample` demonstrate AgentCard exposure at the HTTP root path, optional registry-center registration, HTTP service startup, prompt extraction, validation, and task event pushing. +**Key constraints**: -## 1.8 Prompt Resource Extension -### 1.8.1 Resource Source +1. **Construction-time validation**: `A2ATClient` and `A2ATServer` validate the prompt-resource configuration at construction time. In `local_file` mode, construction fails immediately with a clear error message when `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` is unset, the path does not exist, or the path is not a directory. +2. **Initialization loading**: at construction time, the SDK reads all content under `templates/`, `slots/`, and `scenarios/` of the local root directory once into a read-only snapshot and no longer accesses the file system at runtime; after modifying local files, the SDK process must be restarted for the changes to take effect. +3. **Local-first overlay with built-in fallback**: in `local_file` mode, business content (templates/slots/scenarios of Task-T/Notification-T/Authorization-T) is read from the local root when present and falls back to the built-in classpath copy when missing; an error is only raised when both are missing. Customers therefore only place the templates they override or add — no need to copy the whole built-in tree — and `scenarios/{language}/scenarios.json` is no longer required (a local root without a scenario catalog falls back to the built-in one). Each resource path warns once (deduplicated by path) the first time it falls back to a built-in copy. -Prompt resources can be loaded from two sources: +### 1.5.2 Implementation Steps -| 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. | +#### Step1 Prepare the local resource root directory -To use the built-in resources from the jar, keep the source type as `classpath`: +Following the directory structure of `a2a-t-resources/src/main/resources/prompt_resources`, place resource files under the local root directory as needed: -```properties -A2AT_PROMPT_SOURCE_TYPE=classpath -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LANGUAGE=zh-CN +```text +/ +├── templates/ +│ └── //v1//template.md +│ e.g. templates/Task-T/network-layer/ran-energy-saving/v1/en-US/template.md +├── slots/ +│ └── //v1//slot.json +└── scenarios/ + └── /scenarios.json ``` -### 1.8.2 Local File Extension -When customizing scenarios, prepare local resources with the following structure: +Notes: -```text -prompt_resources/ - scenarios/zh-CN/scenarios.json - slots/{scenario_code}/zh-CN/slot.json - templates/{scenario_code}/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 +1. `` supports `Task-T`, `Notification-T`, and `Authorization-T`; the template version segment is fixed at `v1`; the built-in languages are `zh-CN` and `en-US`. +2. `` is configured as `network-layer/`, e.g. `network-layer/ran-energy-saving`; +3. If the local root directory contains `prompts/`, `templates/Negotiation-T/`, or `negotiation-vocabulary/` directories, they are ignored at construction time with a warning log (these resources are loaded from the classpath). + +#### Step2 Write the resource files + +**Template body template.md**: a Markdown file that references slots with `{{slot name}}` placeholders, for example: + +```markdown +## Operation Type + +{{Operation Type}} (required) +``` + +**Slot definition slot.json**: in JSON Schema (draft 2020-12) format; declare the type, `description`, and `examples` of each slot via `properties`, and optionally use the extension field `x-a2at-value-constraint` to add value constraints (used by LLM extraction and semantic validation), for example: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "Operation Type": { + "type": "string", + "description": "Provide the operation type. Allowed values: create, modify", + "examples": ["create"], + "x-a2at-value-constraint": "Allowed values: create, modify" + } + } +} ``` -Then specify in `.env`: +**Scenario catalog scenarios.json**: the top level is a `scenarios` array; each entry contains `scenario_code`, `scenario_name`, `description`, and `example` fields, for example: + +```json +{ + "scenarios": [ + { + "scenario_code": "ran-energy-saving", + "scenario_name": "Energy-efficiency optimization task dispatch", + "description": "Used to generate A2A-T task requests for energy-efficiency optimization tasks in telecom network management systems.", + "example": "Reduce the energy consumption of the Songshan Lake campus by 30% while ensuring a rate guarantee of no less than 10Mbps." + } + ] +} +``` + +#### Step3 Configure the resource source + +Edit `client.env` (`server.env` on the server side) to switch the resource source to local files and specify the root directory: ```properties +A2AT_LANGUAGE=en-US A2AT_PROMPT_SOURCE_TYPE=local_file -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR={your_prompt_resources_root} -A2AT_LANGUAGE=zh-CN -``` - -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: -``` -## 任务描述 -Handle 5G service fault in Community A - -Complete service restoration - -Identify the root cause of the fault and perform repair -``` - -##### 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 -``` - -##### 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. -``` - - -#### 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. - -## 1.9 Testing Recommendations +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=/opt/a2at/prompt_resources +``` -Common test commands: +Notes: -```bash -mvn test +1. `A2AT_PROMPT_SOURCE_TYPE` takes `classpath` (default) or `local_file`; any other value reports `Unsupported prompt source type` at construction time. +2. In `local_file` mode, `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` is required: when unset, an error is reported prompting you to set `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR`; assembly also fails when the path does not exist or is not a directory. +3. Relative paths of the local root directory are resolved against the directory of the `.env` file; absolute paths are recommended. +4. In `classpath` mode, a configured local root directory is ignored: the generation/validation path logs a warning, while the template-query path ignores it silently. +5. Business content is local-first with built-in fallback: a `template.md`, `slot.json` or `scenarios.json` missing from the local root falls back to the built-in classpath copy (each resource path warns once about the fallback); `scenarios.json` may be omitted entirely. + +**templateUri mapping** + +The `generateTaskPromptFromText` API skips scenario recognition; the template is explicitly specified by the caller via `templateUri`. `templateUri` maps one-to-one to the local directory, with the following mapping rule: + +```text +Local file: /templates/////template.md +templateUri: // ``` -When modifying client prompt generation: +For example, when the local file is `/templates/Task-T/network-layer/site-inspection/v1/en-US/template.md`, the corresponding `templateUri` is `Task-T/network-layer/site-inspection/v1`. -```bash -mvn -pl a2a-t-client -am test +```java +import net.openan.a2at.sdk.core.model.MetadataContent; + +// The facade takes the raw URI string directly +MetadataContent metadata = client.generateTaskPromptFromText( + "Please perform an on-site inspection of station xx, focusing on alarms and performance metrics", + "Task-T/network-layer/site-inspection/v1"); ``` -When modifying server validation: +Facade failure policy for `templateUri`: a null template URI throws `NullPointerException`; a blank or malformed URI (fewer than three segments, or a segment that is not simple) throws `IllegalArgumentException` with the message `Unparseable template URI: `. -```bash -mvn -pl a2a-t-server -am test +To override a built-in template (e.g. `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI`), place `template.md` and `slot.json` at the same relative path under the local root directory and keep using the original constant in the code. In `local_file` mode business templates are local-first with built-in fallback: the local copy is read when present, otherwise the built-in classpath copy is used; the API throws `PromptGenerationException` with the error code `template.not_found` only when a `templateUri` exists neither locally nor built-in. + +#### Step4 Verification and troubleshooting + +After starting the client or server, confirm and troubleshoot as follows: + +1. **Missing resources**: when a template is not found, the generation path throws `PromptGenerationException` and the validation path throws `ContentValidationException`, both with the error code `template.not_found`; the exception message contains the expected file path — complete the directory structure as prompted. +2. **Content not updated**: changes to local files do not take effect without restarting the process; restart the SDK process and construct again. +3. **Negotiation and LLM resources are not customizable**: `Negotiation-T` templates support only the built-in fixed set; negotiation template URIs outside the set are skipped with a warning. + +## 1.6 Logging Configuration and Integration Guide + +### 1.6.1 Logging Mechanism Overview + +The SDK's LLM call logs are emitted through SLF4J on a **dedicated logger** `net.openan.a2at.sdk.llm.call`, decoupled from the application's other logs so the level can be controlled and filtered independently: + +| Log category | Level | Content | Controlled by the switch? | +| --- | --- | --- | --- | +| Call summary logs (`llm_call event=request` / `event=response` / `event=error`) | DEBUG | Timestamp, model, message count/character count, elapsed time (`elapsed_ms`), input/output/total tokens (`prompt_tokens`/`completion_tokens`/`total_tokens`), response content length (`content_chars`), `response_id`; **no payload content** | No; emitted at DEBUG level regardless | +| Full payload logs (`llm_call event=request_body` / `event=response_body`) | DEBUG | Full request messages and response content, **no truncation** | Yes; controlled by `A2AT_LLM_DETAIL_LOG_ENABLED`, default off | + +Summary log example: + +```text +llm_call event=response ts=2026-09-14T08:12:36.012Z provider=openai model=deepseek-v3 elapsed_ms=2556.4 prompt_tokens=512 completion_tokens=120 total_tokens=632 content_chars=344 response_id=chatcmpl-abc123 ``` -When modifying negotiation state machine: +Key points: -```bash -mvn -pl a2a-t-negotiation -am test +1. **No payload is printed by default**: `A2AT_LLM_DETAIL_LOG_ENABLED` defaults to `false`; summary logs are only visible when the embedding application enables DEBUG for the dedicated logger, so production INFO/WARN defaults produce no new output from the SDK. +2. **Two-level control**: first set the level of `net.openan.a2at.sdk.llm.call` to DEBUG through the application's logging framework (controls whether summary logs are visible), then optionally set `A2AT_LLM_DETAIL_LOG_ENABLED=true` (controls whether full payloads are printed). +3. **Risk warning**: enabling the switch prints detailed LLM interaction content, which may expose sensitive information (business templates, slots, negotiation messages, model responses, etc.) or consume large amounts of log space. Use it **only during the project DEBUG phase and keep it disabled in production**. + +### 1.6.2 Configuration + +Add the following to `client.env` / `server.env`: + +```properties +# Whether to print the full LLM request and response payloads (no truncation). Defaults to +# false. WARNING: enabling this prints detailed LLM interaction content, which may expose +# sensitive information and consume large amounts of log space; use ONLY during project +# debugging and keep it disabled in production. +A2AT_LLM_DETAIL_LOG_ENABLED=false ``` -When modifying sample: +| Value | Behavior | +| --- | --- | +| `false` (default, unset, or blank) | Full request/response payloads are not printed | +| `true` | Full payloads are appended to the DEBUG-level logs, without truncation | -```bash -mvn -pl a2a-t-sample -am -DskipTests package +Values are case-insensitive; any other invalid value is recorded as a configuration parse error and the SDK construction throws `LLMConfigError`. + +### 1.6.3 Integration Configuration Guide + +Viewing the LLM call logs requires the embedding application's cooperation (recommended only in debugging environments). The log level (logger) and the output destination/rotation (appender) are two layers of the logging configuration: the level decides which log events are produced, and the appender decides which file the events are written to and how they roll. Complete examples for common frameworks (level + output path + rotation): + +**logback (logback.xml / logback-spring.xml)** + +```xml + + + + + logs/llm-call.log + + + logs/llm-call.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 7 + 2GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %msg%n + + + + + + + + + + + + + ``` + +**Spring Boot (application.yml, Spring Boot 2.4+)** + +```yaml +logging: + level: + net.openan.a2at.sdk.llm.call: DEBUG + # Output path and rotation (applies to Spring Boot's default file appender) + file: + name: logs/llm-call.log + logback: + rollingpolicy: + max-file-size: 100MB + max-history: 7 + total-size-cap: 2GB +``` + +Note: this configuration writes **all** application logs (including LLM call logs) to `logs/llm-call.log` and rotates them per the policy above; `application.yml` cannot assign a dedicated file per logger. To keep LLM call logs in a separate file, add a `logback-spring.xml` (Spring Boot integrates logback by default) and reuse the logback example above. + +**Quarkus (application.properties)** + +```properties +# Level +quarkus.log.category."net.openan.a2at.sdk.llm.call".level=DEBUG +# Write only to the dedicated file handler; do not bubble up to the console +quarkus.log.category."net.openan.a2at.sdk.llm.call".use-parent-handlers=false +# Dedicated file handler: output path +quarkus.log.handler.file.llm-call.enable=true +quarkus.log.handler.file.llm-call.path=logs/llm-call.log +# Rotation: roll daily, keep the latest 7 files +quarkus.log.handler.file.llm-call.rotation.file-suffix=.yyyy-MM-dd +quarkus.log.handler.file.llm-call.rotation.max-backup-index=7 +# Bind the logger to the dedicated file handler +quarkus.log.category."net.openan.a2at.sdk.llm.call".handlers=llm-call +``` + +> Note: the SDK only produces log events and never configures output destinations; both the output path and the rotation policy are entirely decided by the embedding application's appender (the examples above are recommendations). When `A2AT_LLM_DETAIL_LOG_ENABLED` is enabled, every LLM call prints full payloads — set a per-file size cap and a retention window as shown to avoid filling up the disk. If the application provides no SLF4J binding (e.g. logback/log4j2), the log events are discarded with only a single "No SLF4J providers were found" warning. + +## 1.7 Configuration Item List + +The sample configuration file provided by the SDK is `env.example`. When constructing the A2A-T Client and the A2A-T Server, copy the file to the corresponding `client.env` / `server.env`. The configuration items are described below: + +| Configuration Item | Description | +| --- | --- | +| `A2AT_LANGUAGE` | Prompt resource language; built-in `zh-CN` and `en-US`, default `en-US` | +| `A2AT_PROMPT_SOURCE_TYPE` | Prompt resource source; supports `classpath` and `local_file`, default `classpath` | +| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | Local prompt resource root directory; required in `local_file` mode — unset or non-existent paths fail fast at assembly time; business content (templates/slots/scenarios of Task-T/Notification-T/Authorization-T) is loaded from this root local-first with built-in fallback (`scenarios.json` may be omitted locally), while negotiation resources and LLM prompts are loaded from the classpath | +| `A2AT_INPUT_TEXT_MAX_CHARS` | Maximum number of characters for free-text inputs (FromText generation and message validation entry points); exceeding the limit fails fast with error code `input.text_too_long`, default `16384`; structured data that involves no LLM calls is not subject to this limit | +| `A2AT_LLM_PROVIDER` | LLM protocol type; currently only `openai` is supported | +| `A2AT_LLM_MODEL` | Model name | +| `A2AT_LLM_API_KEY` | LLM API key | +| `A2AT_LLM_BASE_URL` | LLM service address (OpenAI-compatible) | +| `A2AT_LLM_MAX_TOKENS` | Maximum number of generated tokens for completion calls; sample value `2000`, provider default when left empty | +| `A2AT_LLM_TEMPERATURE` | Sampling temperature; sample value `0`, provider default when left empty | +| `A2AT_LLM_TIMEOUT_SECONDS` | LLM request timeout in seconds; sample value `60`, provider default when left empty | +| `A2AT_LLM_HISTORY_WINDOW` | Number of session history messages to keep, default `10` | +| `A2AT_LLM_REASONING_EFFORT` | Reasoning effort level; one of `none`/`minimal`/`low`/`medium`/`high`/`xhigh`, not set when left empty | +| `A2AT_LLM_DISABLE_SYSTEM_PROXY` | Whether to bypass the system HTTP proxy when accessing the LLM, default `false` | +| `A2AT_LLM_SSL_VERIFY` | Whether to verify the LLM endpoint TLS certificate chain and hostname; `false` disables both — prefer importing a trusted CA and use `false` only short-term in controlled environments, default `true` | +| `A2AT_LLM_SESSION_MAX_TOTAL` | Maximum total number of tracked sessions, default `300` | +| `A2AT_LLM_SESSION_MAX_PER_PROVIDER` | Maximum number of tracked sessions per provider, default `100` | +| `A2AT_LLM_MAX_ATTEMPTS` | Maximum number of attempts for retryable LLM steps; range 1-10 (out-of-range values are clamped), default `3` | +| `A2AT_LLM_DETAIL_LOG_ENABLED` | Whether to print the full LLM request/response payloads (no truncation), default `false`; enabling it may expose sensitive information or consume log space — use only in the DEBUG phase and keep disabled in production; timestamp/token/latency summary logs are emitted at DEBUG level on the dedicated logger `net.openan.a2at.sdk.llm.call`, see 1.6 | +| `A2AT_NEGOTIATION_STATE_STORE_TYPE` | Negotiation state storage; currently supports `in_memory` | + diff --git a/docs/en/user_guide.md b/docs/en/user_guide.md deleted file mode 100644 index 8c7a9ad0..00000000 --- a/docs/en/user_guide.md +++ /dev/null @@ -1,173 +0,0 @@ -# 1 a2a-t-sdk-java User Guide - -## 1.1 Feature Introduction -### 1.1.1 What is A2A-T? -A2A-T (Agent-to-Agent Telecom) is a multi-agent interconnection protocol for the telecom domain based on the A2A protocol, designed specifically for complex collaboration scenarios in the telecom domain. -Industry-generic agent interconnection protocols primarily focus on agent interconnection and interaction frameworks, with insufficient attention to business scenarios and specific interaction content, resulting in low task completion success rates. Business scenarios in the telecom domain are complex and demanding; operations agent interconnection and collaboration require dedicated protocol support. The A2A-T solution is based on the A2A protocol, with focused application extensions for telecom domain business flow-related information models, task negotiation, and collaboration security enhancement capabilities. - -### 1.1.2 Relationship Between A2A-T SDK and A2A SDK - -The A2A-T protocol is an extension based on the A2A protocol. The A2A-T SDK is provided for the protocol extension content, supporting rapid construction of agents for complex collaboration scenarios in the telecom domain. The A2A-T SDK is independent of the A2A SDK; by integrating both the A2A-T SDK and the A2A SDK simultaneously, you can build agents that support the A2A-T protocol, enabling deterministic, highly reliable, efficient, and secure collaboration among multiple agents in the telecom domain. - -### 1.1.3 Capability Introduction - -a2a-t-sdk-java is the Java SDK implementation of the A2A-T protocol, providing client prompt generation, server prompt validation, and negotiation runtime capabilities. - -Main capabilities include: - -- **Client prompt generation**: Convert natural language or structured input into processed task prompt via `A2ATClient`. -- **Server prompt validation**: Validate the scenario, slot, and semantic consistency of processed task prompt via `A2ATServer`. -- **Negotiation flow**: Support negotiation type: `information`. -- **Modular Maven project**: Organized by layers: core, resources, llm, prompt, negotiation, client, server, sample. -- **A2A Java integration sample**: `a2a-t-sample` demonstrates real HTTP+JSON/REST links based on `a2a-java v1.0.0.Beta1`. - -## 1.2 Application Scenarios - -The Java SDK is typically used in the following scenarios: - -1. A Java client Agent receives user intent and generates an A2A-T processed task prompt. -2. A Java server Agent receives an A2A request, validates the prompt, and proceeds to business execution. -3. Client and server perform multi-round information supplementation or task feasibility confirmation through negotiation context. -4. Work with the registry-center to implement AgentCard registration, query, and A2A invocation address resolution. - -## 1.3 Environment Requirements - -| Item | Requirement | -| --- | --- | -| JDK | 17+ | -| Build Tool | Maven 3.8+ | -| LLM | Accessible OpenAI-compatible service and API Key | -| Registry Center | registry-center must be started before running Java sample | -| A2A Java | sample based on `a2a-java v1.0.0.Beta1` | - -## 1.4 Build and Install - -Execute in the repository root directory: - -```bash -cd {project_path}/a2a-t-sdk-java -mvn -DskipTests package -``` - -To use a local Maven repository directory: - -```bash -mvn "-Dmaven.repo.local=.mvn/repository" -DskipTests package -``` - -Run tests: - -```bash -mvn test -``` - -## 1.5 Prepare Configuration - -The repository root directory provides `client.env` and `server.env`, which can be modified according to your local environment. - -Client configuration example: - -```properties -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=classpath -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY={your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -A2AT_SAMPLE_HOST=127.0.0.1 -A2AT_SAMPLE_PORT=26335 -REGISTRY_CENTER_HOST=127.0.0.1 -REGISTRY_CENTER_PORT=5001 -``` - -Server configuration example: - -```properties -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=classpath -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY={your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -A2AT_SAMPLE_HOST=127.0.0.1 -A2AT_SAMPLE_PORT=26335 -REGISTRY_CENTER_HOST=127.0.0.1 -REGISTRY_CENTER_PORT=5001 -``` - -## 1.6 Quick Run Example - -The Java sample module is located at `a2a-t-sdk-java/a2a-t-sample`. - -### 1.6.1 Prerequisites - -1. Fill in `A2AT_LLM_API_KEY` in `client.env` and `server.env`. -2. Start the sample server before running the sample client. -3. To query AgentCard directly from the sample server, make sure `A2AT_SAMPLE_HOST` and `A2AT_SAMPLE_PORT` in `client.env` match the server listen address. -4. To query AgentCard through registry-center instead, start registry-center and confirm that `REGISTRY_CENTER_HOST` and `REGISTRY_CENTER_PORT` in `client.env` and `server.env` point to the same registry-center. - -### 1.6.2 Build sample - -```bash -cd {project_path}/a2a-t-sdk-java -mvn "-Dmaven.repo.local=.mvn/repository" -pl a2a-t-sample -am -DskipTests package -``` - -### 1.6.3 Start Server - -```bash -java @a2a-t-sample/target/server.javaargs.txt -``` - -After the server starts, it will: - -- Construct a sample AgentCard. -- Register the AgentCard with the registry-center. -- Start the local A2A HTTP service. -- Wait for client requests and validate the A2A-T prompt. - -### 1.6.4 Start Client - -Open another terminal and execute: - -```bash -java @a2a-t-sample/target/client.javaargs.txt -``` - -The client will: - -1. Read sample input from `sample/client/scenario.json`. -2. Query the target AgentCard directly from the sample server root path `GET /` when `A2AT_SAMPLE_HOST` and `A2AT_SAMPLE_PORT` are configured; otherwise query it from registry-center. -3. Generate processed task prompt using `A2ATClient`. -4. Construct an A2A HTTP+JSON/REST request. -5. Receive server task status, message, and artifact events. - - -## 1.7 Configuration File Quick Reference - -| Configuration Item | Description | -| --- | --- | -| `A2AT_LANGUAGE` | Prompt resource language, commonly `zh-CN` | -| `A2AT_PROMPT_SOURCE_TYPE` | Prompt resource source, supports `classpath` and `local_file`; default is `classpath` | -| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | Local prompt resource root directory | -| `A2AT_LLM_PROVIDER` | LLM provider, sample uses `openai_compatible` | -| `A2AT_LLM_MODEL` | Model name | -| `A2AT_LLM_BASE_URL` | LLM service address | -| `A2AT_LLM_API_KEY` | LLM API Key | -| `A2AT_NEGOTIATION_STATE_STORE_TYPE` | Negotiation state storage, currently supports `in_memory` | -| `A2AT_SAMPLE_HOST` | Sample server listen address; the client also uses it to query AgentCard directly from the server root | -| `A2AT_SAMPLE_PORT` | Sample server listen port; the client also uses it to query AgentCard directly from the server root | -| `REGISTRY_CENTER_HOST` | Registry-center address | -| `REGISTRY_CENTER_PORT` | Registry-center port | - -## 1.8 Constraints and Limitations - -1. The current version is Alpha; interfaces and module organization may change with version evolution. -2. Negotiation state currently uses in-memory storage; cross-process persistence is not provided. -3. `A2ATClient` and `A2ATServer` do not automatically discover `.env`; the caller must explicitly pass in the configuration file path. -4. The SDK does not provide authentication, key management, or AgentCard registry-center service capabilities. -5. The sample client can query AgentCard directly from the sample server root path when `A2AT_SAMPLE_HOST` and `A2AT_SAMPLE_PORT` are configured. Registry-center is only required when using registry-center AgentCard query mode. diff --git a/docs/superpowers/plans/2026-10-20-observability-core.md b/docs/superpowers/plans/2026-10-20-observability-core.md new file mode 100644 index 00000000..c8999d8d --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-observability-core.md @@ -0,0 +1,2898 @@ +# A2A-T Java SDK 可观测性(核心模块 + SPI + 门面接线)实施计划 — Plan A + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 按 `docs/superpowers/specs/2026-10-20-observability-java-port-design.md` 落地 `a2a-t-observability` 核心模块、`TelemetryHook` SPI 与门面接线(Plan B:a2a-java 协议层装饰器,单独计划,待本计划落地后编写)。 + +**Architecture:** 双新模块(core 观测 + a2a 协议层);core 定义 `TelemetryHook` SPI,门面二级构造器注入 `@Nullable` hook 包装全部公开方法;OTel api 编译依赖 + sdk/exporter 可选;全同步 SDK,ThreadLocal 作 LLM 用量 stash。 + +**Tech Stack:** Java 17 / Maven 多模块 / OpenTelemetry 1.64.0(api 编译 + sdk、exporter-otlp ``;`exporter-logging` 不在本地 .m2 → Console 降级为内置 SLF4J JSON 导出器)/ JUnit 5(无 Mockito)/ SLF4J + Jackson。 + +## Global Constraints + +- 构建/测试一律在仓库根目录:`mvn -q -pl -am test`;提交前 `mvn -q spotless:apply`(Palantir 格式强制)。 +- 每个新包必须有 `package-info.java` 且 `@NullMarked`(JSpecify);可空参数/字段用 `org.jspecify.annotations.@Nullable`(NullAway ERROR)。 +- 新代码不用 Lombok、不用 Mockito;测试用 JUnit 5 + 手写 stub + logback `ListAppender`。 +- OTel 版本 1.64.0(root pom 属性 + bom import);a2a-java 1.1.0.Final。 +- 观测失败永不破坏业务:插桩点 catch RuntimeException → WARN(logger `net.openan.a2at.sdk.observability`)→ 吞掉;业务异常原样抛。 +- 命名注册表与 spec §5 逐字一致;环境变量与 spec §7 一致。 +- 开关读取顺序(全部键):`System.getProperty(key)` → `System.getenv(key)` → 默认值(比 Python 多一级 getProperty,便于测试覆盖;行为等价)。 +- 提交信息 conventional commits(`feat(observability): ...`)。 + +## File Structure(Plan A 产出) + +``` +pom.xml (改: modules + 属性 + otel bom import + a2a-java 版本管理) +a2a-t-bom/pom.xml (改: +2 模块) +a2a-t-observability/pom.xml (新) +a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ + package-info.java, A2ATObservabilityConfig.java, A2ATAttributes.java, + AttributeExtractors.java, EventInfo.java, ObservabilityRuntime.java, + A2ATSpan.java, A2ATCurrentSpan.java, CurrentSpans.java, TracePropagation.java, + PayloadExtractor.java, PayloadSanitizer.java, LlmUsageStash.java, LlmUsageStashHolder.java, + ObservabilityLLMClient.java, MetricHistograms.java, NegotiationMetrics.java, + ObservabilityLogs.java, ConsoleExporters.java, ObservabilitySetup.java +a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ (镜像测试) +a2a-t-observability-a2a/pom.xml (新: 骨架,Plan B 填充) +a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/ + package-info.java, TelemetryHook.java, TelemetryOperation.java +a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java (改: hook 接线) +a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java (改: hook 接线) +env.example (改: 可观测性小节) +``` + +> spec §3 的 attributes.py 在 Java 拆分为两半:core 模块放常量 + Map 级提取器 + `EventInfo`;a2a-java 类型化分类器(`StreamingEventKind`/`MessageSendParams` instanceof 分派)放 a2a 模块(Plan B)——依赖方向要求。 + +--- + +### Task 1: 模块骨架与依赖登记 + +**Files:** +- Modify: `pom.xml`(modules、properties、dependencyManagement) +- Modify: `a2a-t-bom/pom.xml` +- Create: `a2a-t-observability/pom.xml`、`a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/package-info.java` +- Create: `a2a-t-observability-a2a/pom.xml`、`a2a-t-observability-a2a/src/main/java/net/openan/a2at/sdk/observability/a2a/package-info.java` + +**Interfaces:** +- Produces: 两个可编译空模块;root pom 属性 `opentelemetry.version=1.64.0`、`a2a-java.version=1.1.0.Final`;后续任务靠 root `dependencyManagement` 省略版本号。 + +- [ ] **Step 1: 根 pom 三处修改** + +`` 中 `a2a-t-server` 之后追加: + +```xml + a2a-t-observability + a2a-t-observability-a2a +``` + +`` 中 `2.0.17` 之后追加: + +```xml + 1.64.0 + 1.1.0.Final +``` + +`` 中 `jspecify` 条目之后追加: + +```xml + + io.opentelemetry + opentelemetry-bom + ${opentelemetry.version} + pom + import + + + org.a2aproject.sdk + a2a-java-sdk-client-transport-spi + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-spec + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-server-common + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-jsonrpc-common + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-transport-rest + ${a2a-java.version} + +``` + +- [ ] **Step 2: BOM 登记** — `a2a-t-bom/pom.xml` 的 `a2a-t-server` 条目后追加: + +```xml + + net.openan.a2a-t.sdk + a2a-t-observability + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-observability-a2a + ${project.version} + +``` + +- [ ] **Step 3: 核心模块 pom** — `a2a-t-observability/pom.xml`(父 POM + ${project.version} 依赖,模板同 `a2a-t-llm/pom.xml`): + +```xml + + 4.0.0 + + net.openan.a2a-t.sdk + a2a-t-parent + ${revision} + + a2a-t-observability + a2a-t Observability + Observability core for the a2a-t Java SDK: config, setup, manual span API, propagation, logs, metrics, LLM decorator. + + + net.openan.a2a-t.sdk + a2a-t-core + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-llm + ${project.version} + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-sdk + true + + + io.opentelemetry + opentelemetry-exporter-otlp + true + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter + test + + + ch.qos.logback + logback-classic + 1.4.14 + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + +``` + +(`opentelemetry-sdk-testing` 经 bom 管理 1.64.0,提供 `InMemorySpanExporter`/`InMemoryMetricReader`,测试基建统一走它。) + +- [ ] **Step 4: a2a 模块骨架 pom** — `a2a-t-observability-a2a/pom.xml`: + +```xml + + 4.0.0 + + net.openan.a2a-t.sdk + a2a-t-parent + ${revision} + + a2a-t-observability-a2a + a2a-t Observability A2A + Protocol-level observability decorators for a2a-java (transport, request handler, executor, push sender). Filled by the observability-a2a plan. + + + net.openan.a2a-t.sdk + a2a-t-observability + ${project.version} + + + org.a2aproject.sdk + a2a-java-sdk-client-transport-spi + + + org.a2aproject.sdk + a2a-java-sdk-spec + + + org.a2aproject.sdk + a2a-java-sdk-server-common + + + org.a2aproject.sdk + a2a-java-sdk-jsonrpc-common + + + org.a2aproject.sdk + a2a-java-sdk-transport-rest + + + org.junit.jupiter + junit-jupiter + test + + + +``` + +- [ ] **Step 5: package-info** — 两个文件,内容模式一致: + +```java +/** Observability core for the a2a-t Java SDK. */ +@NullMarked +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.NullMarked; +``` + +(a2a 模块:`package net.openan.a2at.sdk.observability.a2a;`,注释 `/** Protocol-level observability decorators for a2a-java. */`) + +- [ ] **Step 6: 验证编译** — 根目录运行 `mvn -q -pl a2a-t-observability,a2a-t-observability-a2a -am compile`,期望 BUILD SUCCESS(无 OTel/“a2a-java 缺失”报错)。 + +- [ ] **Step 7: 提交** — `mvn -q spotless:apply; git add pom.xml a2a-t-bom/pom.xml a2a-t-observability a2a-t-observability-a2a; git commit -m "feat(observability): add observability module skeletons and dependency registration"` + +### Task 2: TelemetryHook SPI(a2a-t-core) + +**Files:** +- Create: `a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/package-info.java` +- Create: `a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryOperation.java` +- Create: `a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryHook.java` +- Test: `a2a-t-core/src/test/java/net/openan/a2at/sdk/core/telemetry/TelemetryHookTest.java` + +**Interfaces:** +- Produces: `TelemetryHook.startSdkOperation(String role, String method) -> TelemetryOperation`、`TelemetryHook.reportNegotiationRounds(String negotiationId, String outcome, long totalRounds)`、`TelemetryHook.NO_OP`;`TelemetryOperation`(AutoCloseable)`setAttribute(String, Object)` / `markSuccess()` / `markError(Throwable)` / `close()` / `TelemetryOperation.NO_OP`。Task 10 门面与 Plan B `SdkOperationSpans` 依赖此面。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.core.telemetry; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class TelemetryHookTest { + + @Test + void noOpHookAbsorbsEverything() { + TelemetryOperation operation = TelemetryHook.NO_OP.startSdkOperation("client", "getPrompts"); + assertSame(TelemetryOperation.NO_OP, operation); + assertDoesNotThrow(() -> { + operation.setAttribute("k", "v"); + operation.markSuccess(); + operation.markError(new RuntimeException("x")); + operation.close(); + TelemetryHook.NO_OP.reportNegotiationRounds("n1", "accept", 3); + }); + } + + @Test + void customHookReceivesCalls() { + List started = new ArrayList<>(); + List rounds = new ArrayList<>(); + TelemetryHook hook = new TelemetryHook() { + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + started.add(role + "." + method); + return TelemetryOperation.NO_OP; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + }; + hook.startSdkOperation("server", "checkTaskPrompt").close(); + hook.reportNegotiationRounds("N1", "reject", 2); + assertTrue(started.contains("server.checkTaskPrompt")); + assertTrue(rounds.contains("N1:reject:2")); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-core test "-Dtest=TelemetryHookTest"`,期望编译错误(类不存在)。 + +- [ ] **Step 3: 实现** + +`TelemetryOperation.java`: + +```java +package net.openan.a2at.sdk.core.telemetry; + +/** One observed SDK operation; AutoCloseable for try-with-resources. */ +public interface TelemetryOperation extends AutoCloseable { + + /** Attaches one attribute to the underlying observation. No-op values are ignored. */ + void setAttribute(String key, Object value); + + /** Marks the operation as business-successful before close. */ + void markSuccess(); + + /** Marks the operation as failed; the Throwable is recorded verbatim. */ + void markError(Throwable error); + + /** Ends the operation. Idempotent. */ + @Override + void close(); + + TelemetryOperation NO_OP = + new TelemetryOperation() { + @Override + public void setAttribute(String key, Object value) {} + + @Override + public void markSuccess() {} + + @Override + public void markError(Throwable error) {} + + @Override + public void close() {} + }; +} +``` + +`TelemetryHook.java`: + +```java +package net.openan.a2at.sdk.core.telemetry; + +/** + * Telemetry SPI consumed by SDK facades. Implementations live in optional observability modules; + * the default {@link #NO_OP} keeps the core dependency-free. + */ +public interface TelemetryHook { + + /** Starts one facade-operation observation named {@code a2at.sdk..}. */ + TelemetryOperation startSdkOperation(String role, String method); + + /** Reports one terminal negotiation round count (outcome: accept/reject). */ + void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds); + + TelemetryHook NO_OP = + new TelemetryHook() { + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + return TelemetryOperation.NO_OP; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) {} + }; +} +``` + +`package-info.java`:`@NullMarked` 模式,注释 `/** Telemetry SPI for the a2a-t Java SDK core. */`。 + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-core test "-Dtest=TelemetryHookTest"`,期望 PASS。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-core; git commit -m "feat(observability): add TelemetryHook SPI to core"` + +--- + +### Task 3: A2ATObservabilityConfig + +**Files:** +- Create: `a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java` +- Test: `a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java` + +**Interfaces:** +- Produces: record 15 字段(顺序即下码)+ `fromEnv()` / `fromMap(Map)` / `builder()`;布尔键解析 truthy 集合 `{1,true,yes,on}`(大小写不敏感,其余 false);协议别名 `http` → `http/protobuf`,非法 → `grpc`;`A2AT_SERVICE_NAME` 优先于 `OTEL_SERVICE_NAME`;正则字段仅编程式设置(无 env 键),空串 = opt-out、null = 用内置默认(消费者决定)。Task 4/8/9 与 Plan B 全部消费。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class A2ATObservabilityConfigTest { + + @Test + void defaultsMatchSpec() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv(); + assertTrue(config.enabled()); + assertTrue(config.traceEnabled()); + assertTrue(config.metricEnabled()); + assertTrue(config.logEnabled()); + assertEquals("grpc", config.protocol()); + assertEquals(A2ATObservabilityConfig.DEFAULT_PAYLOAD_LOG_MAX_LENGTH, config.payloadLogMaxLength()); + assertFalse(config.extractRequest()); + assertFalse(config.extractResponse()); + assertFalse(config.payloadLogEnabled()); + } + + @Test + void mapValuesBeatDefaults() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of( + "A2AT_OBSERVABILITY_ENABLED", "false", + "A2AT_TRACE_ENABLED", "false", + "OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318", + "A2AT_EXPORTER_PROTOCOL", "http", + "A2AT_SERVICE_NAME", "svc", + "A2AT_LOGS_PAYLOAD_ENABLED", "TRUE", + "A2AT_LOGS_PAYLOAD_MAX_LENGTH", "128")); + assertFalse(config.enabled()); + assertFalse(config.traceEnabled()); + assertTrue(config.metricEnabled()); + assertEquals("http/protobuf", config.protocol()); + assertEquals("http://collector:4318", config.endpoint()); + assertEquals("svc", config.serviceName()); + assertTrue(config.payloadLogEnabled()); + assertEquals(128, config.payloadLogMaxLength()); + } + + @Test + void invalidProtocolFallsBackToGrpcAndInvalidIntFallsBackToDefault() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of( + "A2AT_EXPORTER_PROTOCOL", "udp", "A2AT_LOGS_PAYLOAD_MAX_LENGTH", "abc")); + assertEquals("grpc", config.protocol()); + assertEquals(4096, config.payloadLogMaxLength()); + } + + @Test + void builderBeatsMap() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of("A2AT_SERVICE_NAME", "from-map")) + .toBuilder() + .serviceName("explicit") + .taskTypeRegex("") + .build(); + assertEquals("explicit", config.serviceName()); + assertEquals("", config.taskTypeRegex()); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=A2ATObservabilityConfigTest"`(编译错误)。 + +- [ ] **Step 3: 实现** — `A2ATObservabilityConfig.java`: + +```java +package net.openan.a2at.sdk.observability; + +import java.util.Locale; +import java.util.Map; +import java.util.function.UnaryOperator; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Observability configuration: explicit builder > .env map > system env > spec defaults. */ +public record A2ATObservabilityConfig( + boolean enabled, + boolean traceEnabled, + boolean metricEnabled, + boolean logEnabled, + @Nullable String endpoint, + String protocol, + @Nullable String serviceName, + @Nullable String taskTypeRegex, + @Nullable String notificationTopicRegex, + @Nullable String authorizationPolicyOperationTypeRegex, + boolean extractRequest, + boolean extractResponse, + boolean payloadLogEnabled, + int payloadLogMaxLength, + @Nullable UnaryOperator payloadRedactor) { + + private static final Logger LOG = LoggerFactory.getLogger(A2ATObservabilityConfig.class); + + public static final String ENV_ENABLED = "A2AT_OBSERVABILITY_ENABLED"; + public static final String ENV_TRACE = "A2AT_TRACE_ENABLED"; + public static final String ENV_METRIC = "A2AT_METRIC_ENABLED"; + public static final String ENV_LOG = "A2AT_LOG_ENABLED"; + public static final String ENV_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"; + public static final String ENV_PROTOCOL = "A2AT_EXPORTER_PROTOCOL"; + public static final String ENV_SERVICE_NAME = "A2AT_SERVICE_NAME"; + public static final String ENV_OTEL_SERVICE_NAME = "OTEL_SERVICE_NAME"; + public static final String ENV_AGENT_ROLE = "A2AT_AGENT_ROLE"; + public static final String ENV_EXTRACT_REQUEST = "A2AT_EXTRACT_REQUEST"; + public static final String ENV_EXTRACT_RESPONSE = "A2AT_EXTRACT_RESPONSE"; + public static final String ENV_PAYLOAD_LOG_ENABLED = "A2AT_LOGS_PAYLOAD_ENABLED"; + public static final String ENV_PAYLOAD_LOG_MAX_LENGTH = "A2AT_LOGS_PAYLOAD_MAX_LENGTH"; + public static final String ENV_CONSOLE_EXPORTER = "A2AT_CONSOLE_EXPORTER"; + + public static final String DEFAULT_PROTOCOL = "grpc"; + public static final int DEFAULT_PAYLOAD_LOG_MAX_LENGTH = 4096; + public static final String DEFAULT_SERVICE_NAME = "a2a-t-agent"; + + public static A2ATObservabilityConfig fromEnv() { + return fromMap(Map.of()); + } + + /** @param values .env file values (take precedence over system env); missing keys fall back to env then defaults */ + public static A2ATObservabilityConfig fromMap(Map values) { + Builder b = new Builder(); + b.enabled = bool(values, ENV_ENABLED, true); + b.traceEnabled = bool(values, ENV_TRACE, true); + b.metricEnabled = bool(values, ENV_METRIC, true); + b.logEnabled = bool(values, ENV_LOG, true); + b.endpoint = str(values, ENV_ENDPOINT); + b.protocol = normalizeProtocol(str(values, ENV_PROTOCOL)); + String fromSdkKey = str(values, ENV_SERVICE_NAME); + b.serviceName = fromSdkKey != null ? fromSdkKey : str(values, ENV_OTEL_SERVICE_NAME); + b.extractRequest = bool(values, ENV_EXTRACT_REQUEST, false); + b.extractResponse = bool(values, ENV_EXTRACT_RESPONSE, false); + b.payloadLogEnabled = bool(values, ENV_PAYLOAD_LOG_ENABLED, false); + b.payloadLogMaxLength = intOr(values, ENV_PAYLOAD_LOG_MAX_LENGTH, DEFAULT_PAYLOAD_LOG_MAX_LENGTH); + return b.build(); + } + + public static Builder builder() { + return new Builder(); + } + + public Builder toBuilder() { + Builder b = new Builder(); + b.enabled = enabled; + b.traceEnabled = traceEnabled; + b.metricEnabled = metricEnabled; + b.logEnabled = logEnabled; + b.endpoint = endpoint; + b.protocol = protocol; + b.serviceName = serviceName; + b.agentRole = resolveAgentRole(); + b.taskTypeRegex = taskTypeRegex; + b.notificationTopicRegex = notificationTopicRegex; + b.authorizationPolicyOperationTypeRegex = authorizationPolicyOperationTypeRegex; + b.extractRequest = extractRequest; + b.extractResponse = extractResponse; + b.payloadLogEnabled = payloadLogEnabled; + b.payloadLogMaxLength = payloadLogMaxLength; + b.payloadRedactor = payloadRedactor; + return b; + } + + /** Agent role: env-only opt-in (no builder default). */ + public @Nullable String resolveAgentRole() { + String role = System.getProperty(ENV_AGENT_ROLE, System.getenv(ENV_AGENT_ROLE)); + return role == null || role.isBlank() ? null : role.trim(); + } + + static String normalizeProtocol(@Nullable String raw) { + String value = (raw == null ? DEFAULT_PROTOCOL : raw).trim().toLowerCase(Locale.ROOT); + if (value.equals("grpc")) { + return "grpc"; + } + if (value.equals("http") || value.equals("http/protobuf")) { + return "http/protobuf"; + } + LOG.warn("a2at: invalid exporter protocol {}; falling back to grpc", raw); + return DEFAULT_PROTOCOL; + } + + private static @Nullable String str(Map values, String key) { + String v = values.get(key); + if (v != null && !v.isBlank()) { + return v.trim(); + } + String env = System.getenv(key); + return env == null || env.isBlank() ? null : env.trim(); + } + + private static boolean bool(Map values, String key, boolean def) { + String v = str(values, key); + if (v == null) { + return def; + } + return switch (v.toLowerCase(Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + private static int intOr(Map values, String key, int def) { + String v = str(values, key); + if (v == null) { + return def; + } + try { + return Integer.parseInt(v); + } catch (NumberFormatException e) { + return def; + } + } + + public static final class Builder { + private boolean enabled = true; + private boolean traceEnabled = true; + private boolean metricEnabled = true; + private boolean logEnabled = true; + private @Nullable String endpoint; + private String protocol = DEFAULT_PROTOCOL; + private @Nullable String serviceName; + private @Nullable String agentRole; + private @Nullable String taskTypeRegex; + private @Nullable String notificationTopicRegex; + private @Nullable String authorizationPolicyOperationTypeRegex; + private boolean extractRequest; + private boolean extractResponse; + private boolean payloadLogEnabled; + private int payloadLogMaxLength = DEFAULT_PAYLOAD_LOG_MAX_LENGTH; + private @Nullable UnaryOperator payloadRedactor; + + public Builder enabled(boolean v) { this.enabled = v; return this; } + public Builder traceEnabled(boolean v) { this.traceEnabled = v; return this; } + public Builder metricEnabled(boolean v) { this.metricEnabled = v; return this; } + public Builder logEnabled(boolean v) { this.logEnabled = v; return this; } + public Builder endpoint(@Nullable String v) { this.endpoint = v; return this; } + public Builder protocol(String v) { this.protocol = normalizeProtocol(v); return this; } + public Builder serviceName(@Nullable String v) { this.serviceName = v; return this; } + public Builder agentRole(@Nullable String v) { this.agentRole = v; return this; } + public Builder taskTypeRegex(@Nullable String v) { this.taskTypeRegex = v; return this; } + public Builder notificationTopicRegex(@Nullable String v) { this.notificationTopicRegex = v; return this; } + public Builder authorizationPolicyOperationTypeRegex(@Nullable String v) { this.authorizationPolicyOperationTypeRegex = v; return this; } + public Builder extractRequest(boolean v) { this.extractRequest = v; return this; } + public Builder extractResponse(boolean v) { this.extractResponse = v; return this; } + public Builder payloadLogEnabled(boolean v) { this.payloadLogEnabled = v; return this; } + public Builder payloadLogMaxLength(int v) { this.payloadLogMaxLength = v; return this; } + public Builder payloadRedactor(@Nullable UnaryOperator v) { this.payloadRedactor = v; return this; } + + public A2ATObservabilityConfig build() { + if (payloadLogMaxLength <= 0) { + throw new IllegalArgumentException("payloadLogMaxLength must be positive"); + } + String name = serviceName != null ? serviceName : System.getenv(ENV_SERVICE_NAME); + if (name == null || name.isBlank()) { + name = System.getenv(ENV_OTEL_SERVICE_NAME); + } + return new A2ATObservabilityConfig( + enabled, traceEnabled, metricEnabled, logEnabled, endpoint, protocol, + name == null || name.isBlank() ? DEFAULT_SERVICE_NAME : name.trim(), + agentRole, taskTypeRegex, notificationTopicRegex, authorizationPolicyOperationTypeRegex, + extractRequest, extractResponse, payloadLogEnabled, payloadLogMaxLength, payloadRedactor); + } + } +} +``` + +注意:record 显式包含 `agentRole` 字段——在头部 15 字段列表中补上(`serviceName` 之后),构造器调用相应对齐。 + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=A2ATObservabilityConfigTest"`,期望 PASS。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add A2ATObservabilityConfig"` + +--- + +### Task 4: A2ATAttributes + EventInfo + AttributeExtractors + +**Files:** +- Create: `.../observability/A2ATAttributes.java`、`.../observability/EventInfo.java`、`.../observability/AttributeExtractors.java` +- Test: `.../observability/AttributeExtractorsTest.java` + +**Interfaces:** +- Produces(后续所有任务与 Plan B 消费): + - `A2ATAttributes`:全部属性常量(spec §5.2 逐字值) + - `EventInfo(String kind, @Nullable String taskId, @Nullable String taskStatus, boolean finalEvent, boolean terminal, @Nullable Map messageMetadata, @Nullable String artifactName)` + `EventInfo.UNKNOWN` + - `AttributeExtractors.extensionNameFromUri(@Nullable String)`、`extensionNameFromMetadata(Map)`、`negotiationAttributes(Map)`、`isNegotiationMessage(Message)`、`normalizeTaskState(@Nullable TaskState)`、`classifyEvent(StreamingEventKind)`、`requestAttributes(String operationName, @Nullable Object params)`、`messageDoubleAttributes(Message, boolean clientSide)`、`regexAttributes(Map, A2ATObservabilityConfig)` + - 常量 `NEGOTIATION_EXTENSION = "Negotiation-T"`、`NEGOTIATION_SUFFIX = "-negotiation"`、`EVENT_LOG_KINDS` + +> 类型化提取器(`Message`/`StreamingEventKind`/`MessageSendParams` 参数)**不引 a2a-java 依赖就无法编译**——但 a2a-java 不在本模块依赖里。按 spec §3 的 Java 拆分:本任务只落 **Map 级**部分(extension/negotiation/regex/常量/EventInfo);`classifyEvent`/`requestAttributes`/`messageDoubleAttributes` 等类型化分派放 **a2a 模块**(Plan B 的 `A2AJavaAttributeExtractors`)。本任务接口因此收窄为: + +- `extensionNameFromUri(@Nullable String uri) -> @Nullable String` +- `extensionNameFromMetadata(Map metadata) -> @Nullable String` +- `isNegotiationMessage(Map metadata) -> boolean`(metadata 键含 Negotiation-T 扩展 URI) +- `negotiationAttributes(Map metadata) -> Map`(读 `negotiationContext.id/round/maxRounds/performative`,终态 performative 附 `total_rounds`) +- `normalizeTaskStateName(@Nullable String enumName) -> @Nullable String`(`TASK_STATE_COMPLETED` → `completed`) +- `regexAttributes(Map metadata, A2ATObservabilityConfig config) -> Map`(null regex → DEFAULT 回退、空串 opt-out、尾括号清洗) + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AttributeExtractorsTest { + + private static final String NEGOTIATION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T"; + + @Test + void extensionNameFromUriParsesBase() { + assertEquals("Negotiation-T", AttributeExtractors.extensionNameFromUri(NEGOTIATION_URI)); + assertEquals("Task-T", AttributeExtractors.extensionNameFromUri( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/sub")); + assertNull(AttributeExtractors.extensionNameFromUri("https://other.example/ext")); + assertNull(AttributeExtractors.extensionNameFromUri(null)); + } + + @Test + void negotiationAttributesExtractedFromMetadata() { + Map metadata = Map.of(NEGOTIATION_URI, "v1", "negotiationContext", + Map.of("id", "N001", "round", 3, "maxRounds", 5, "performative", "ACCEPT")); + Map attrs = AttributeExtractors.negotiationAttributes(metadata); + assertEquals("N001", attrs.get(A2ATAttributes.ATTR_NEGOTIATION_ID)); + assertEquals(3, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_ROUND)); + assertEquals(5, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_MAX_ROUNDS)); + assertEquals("ACCEPT", attrs.get(A2ATAttributes.ATTR_NEGOTIATION_PERFORMATIVE)); + assertEquals(3, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS)); + } + + @Test + void nonTerminalPerformativeOmitsTotalRounds() { + Map attrs = AttributeExtractors.negotiationAttributes( + Map.of("negotiationContext", Map.of("id", "N1", "round", 1, "performative", "PROPOSE"))); + assertFalse(attrs.containsKey(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS)); + assertTrue(AttributeExtractors.isNegotiationMessage(Map.of(NEGOTIATION_URI, "v1"))); + assertFalse(AttributeExtractors.isNegotiationMessage(Map.of("k", "v"))); + } + + @Test + void regexAttributesFallBackToDefaultsAndHonorOptOut() { + Map metadata = Map.of("text", "## 任务类型(Task Type)\n\n配置下发\n## 通知主题\n\nalarm"); + A2ATObservabilityConfig fallback = A2ATObservabilityConfig.fromEnv(); + Map attrs = AttributeExtractors.regexAttributes(metadata, fallback); + assertEquals("配置下发", attrs.get(A2ATAttributes.ATTR_TASK_TYPE)); + assertEquals("alarm", attrs.get(A2ATAttributes.ATTR_NOTIFICATION_TOPIC)); + + Map optedOut = AttributeExtractors.regexAttributes( + metadata, A2ATObservabilityConfig.fromEnv().toBuilder().taskTypeRegex("").build()); + assertFalse(optedOut.containsKey(A2ATAttributes.ATTR_TASK_TYPE)); + } + + @Test + void terminalStateNamesNormalize() { + assertEquals("completed", AttributeExtractors.normalizeTaskStateName("TASK_STATE_COMPLETED")); + assertEquals("canceled", AttributeExtractors.normalizeTaskStateName("TASK_STATE_CANCELED")); + assertNull(AttributeExtractors.normalizeTaskStateName(null)); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("status")); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("artifact")); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("completed")); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=AttributeExtractorsTest"`。 + +- [ ] **Step 3: 实现** + +`A2ATAttributes.java`(常量值与 spec §5.2 / Python attributes.py 逐字一致): + +```java +package net.openan.a2at.sdk.observability; + +import java.util.Set; + +/** Span attribute keys and shared constants (values byte-identical to the Python SDK). */ +public final class A2ATAttributes { + + public static final String ATTR_EXTENSION_NAME = "gen_ai.agent.a2at.extension.name"; + public static final String ATTR_TASK_ID = "gen_ai.agent.a2at.task.id"; + public static final String ATTR_TASK_STATUS = "gen_ai.agent.a2at.task.status"; + public static final String ATTR_TASK_TYPE = "gen_ai.agent.a2at.task.type"; + public static final String ATTR_NEGOTIATION_ID = "gen_ai.agent.a2at.negotiation.id"; + public static final String ATTR_NEGOTIATION_ROUND = "gen_ai.agent.a2at.negotiation.round"; + public static final String ATTR_NEGOTIATION_MAX_ROUNDS = "gen_ai.agent.a2at.negotiation.max_rounds"; + public static final String ATTR_NEGOTIATION_PERFORMATIVE = "gen_ai.agent.a2at.negotiation.performative"; + public static final String ATTR_NEGOTIATION_TOTAL_ROUNDS = "gen_ai.agent.a2at.negotiation.total_rounds"; + public static final String ATTR_NOTIFICATION_TOPIC = "gen_ai.agent.a2at.notification.topic"; + public static final String ATTR_STREAMING_EVENT_KIND = "gen_ai.agent.a2at.streaming.event.kind"; + public static final String ATTR_PUSH_NOTIFICATION_URL = "gen_ai.agent.a2at.push.notification.url"; + public static final String ATTR_SPAN_SIDE = "a2at.span.side"; + public static final String ATTR_AUTHORIZATION_POLICY_OPERATION_TYPE = + "gen_ai.agent.a2at.authorization.policy.operation.type"; + public static final String ATTR_AUTHORIZATION_POLICY_ID = "authorization.policy.id"; + public static final String ATTR_AUTHORIZATION_OPERATION_TYPE = "authorization.operation_type"; + public static final String ATTR_AUTHORIZATION_OPERATION_RISK_LEVEL = "authorization.operation_risk_level"; + public static final String ATTR_A2A_OPERATION_NAME = "gen_ai.agent.a2a.operation.name"; + public static final String ATTR_A2A_CONTEXT_ID = "gen_ai.agent.a2a.context_id"; + public static final String ATTR_A2A_TASK_ID = "gen_ai.agent.a2a.task_id"; + public static final String ATTR_A2A_MESSAGE_ID = "gen_ai.agent.a2a.message_id"; + public static final String ATTR_A2A_ROLE = "gen_ai.agent.a2a.role"; + public static final String ATTR_A2A_EXTENSIONS = "gen_ai.agent.a2a.extensions"; + public static final String ATTR_A2A_PARTS_NUMBER = "gen_ai.agent.a2a.parts.number"; + public static final String ATTR_A2A_PROTOCOL = "gen_ai.agent.a2a.protocol"; + public static final String ATTR_A2A_REQUEST = "gen_ai.agent.a2a.request"; + public static final String ATTR_A2A_RESPONSE = "gen_ai.agent.a2a.response"; + public static final String ATTR_GEN_AI_OPERATION_NAME = "gen_ai.operation.name"; + public static final String ATTR_GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id"; + public static final String ATTR_GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"; + public static final String ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"; + public static final String ATTR_GEN_AI_REQUEST_MODEL = "gen_ai.request.model"; + public static final String ATTR_GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"; + public static final String ATTR_GEN_AI_TOKEN_TYPE = "gen_ai.token.type"; + public static final String ATTR_GEN_AI_PROVIDER_NAME = "gen_ai.provider.name"; + public static final String ATTR_STREAMING = "streaming"; + public static final String ATTR_ERROR_TYPE = "error.type"; + + public static final String NEGOTIATION_EXTENSION = "Negotiation-T"; + public static final String NEGOTIATION_SUFFIX = "-negotiation"; + + public static final Set EVENT_LOG_KINDS = Set.of( + "status", "artifact", "completed", "canceled", "failed", "rejected"); + + private A2ATAttributes() {} +} +``` + +`EventInfo.java`: + +```java +package net.openan.a2at.sdk.observability; + +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** Classified view of one A2A stream event (protocol-agnostic). */ +public record EventInfo( + String kind, + @Nullable String taskId, + @Nullable String taskStatus, + boolean finalEvent, + boolean terminal, + @Nullable Map messageMetadata, + @Nullable String artifactName) { + + public static final EventInfo UNKNOWN = + new EventInfo("unknown", null, null, false, false, null, null); + + public boolean isNegotiationCandidate() { + return "message".equals(kind); + } +} +``` + +`AttributeExtractors.java`: + +```java +package net.openan.a2at.sdk.observability; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** Metadata-level attribute extractors (Map in / attributes out); never raises. */ +public final class AttributeExtractors { + + public static final String EXTENSION_BASE = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/"; + public static final String DEFAULT_TASK_TYPE_REGEX = "##\\s*任务类型\\(Task Type\\)\\s*\\n+\\s*(\\S[^\\n]*)"; + public static final String DEFAULT_NOTIFICATION_TOPIC_REGEX = "##\\s*通知主题\\s*\\n+\\s*(\\S[^\\n]*)"; + public static final String DEFAULT_AUTHORIZATION_REGEX = "##\\s*授权策略的操作类型\\s*\\n+\\s*(\\S[^\\n]*)"; + private static final java.util.Set TERMINAL_PERFORMATIVES = java.util.Set.of("ACCEPT", "REJECT", "ABORT"); + + private AttributeExtractors() {} + + public static @Nullable String extensionNameFromUri(@Nullable String uri) { + if (uri == null || !uri.startsWith(EXTENSION_BASE)) { + return null; + } + String rest = uri.substring(EXTENSION_BASE.length()); + int slash = rest.indexOf('/'); + String name = slash >= 0 ? rest.substring(0, slash) : rest; + return name.isEmpty() ? null : name; + } + + public static @Nullable String extensionNameFromMetadata(Map metadata) { + for (String key : metadata.keySet()) { + String name = extensionNameFromUri(key); + if (name != null) { + return name; + } + } + return null; + } + + public static boolean isNegotiationMessage(Map metadata) { + return AttributeExtractors.NEGOTIATION_EXTENSION.equals(extensionNameFromMetadata(metadata)); + } + + @SuppressWarnings("unchecked") + public static Map negotiationAttributes(Map metadata) { + Map attrs = new LinkedHashMap<>(); + Object contextObj = metadata.get("negotiationContext"); + if (!(contextObj instanceof Map context)) { + return attrs; + } + Map ctx = (Map) context; + Object id = ctx.get("id"); + if (id instanceof String s && !s.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_ID, s); + } + Integer round = wireInt(ctx.get("round")); + if (round != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_ROUND, round); + } + Integer maxRounds = wireInt(ctx.get("maxRounds")); + if (maxRounds != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_MAX_ROUNDS, maxRounds); + } + Object performative = ctx.get("performative"); + if (performative instanceof String p && !p.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_PERFORMATIVE, p); + if (TERMINAL_PERFORMATIVES.contains(p) && round != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS, round); + } + } + return attrs; + } + + private static @Nullable Integer wireInt(Object value) { + if (value instanceof Integer i) { + return i; + } + if (value instanceof Number n && n.longValue() == n.doubleValue()) { + return n.intValue(); + } + return null; + } + + public static @Nullable String normalizeTaskStateName(@Nullable String enumName) { + if (enumName == null) { + return null; + } + String prefix = "TASK_STATE_"; + return enumName.startsWith(prefix) ? enumName.substring(prefix.length()).toLowerCase(java.util.Locale.ROOT) + : enumName.toLowerCase(java.util.Locale.ROOT); + } + + public static Map regexAttributes(Map metadata, A2ATObservabilityConfig config) { + Map attrs = new LinkedHashMap<>(); + putRegex(attrs, A2ATAttributes.ATTR_TASK_TYPE, metadata, + config.taskTypeRegex() != null ? config.taskTypeRegex() : DEFAULT_TASK_TYPE_REGEX, false); + putRegex(attrs, A2ATAttributes.ATTR_NOTIFICATION_TOPIC, metadata, + config.notificationTopicRegex() != null ? config.notificationTopicRegex() + : DEFAULT_NOTIFICATION_TOPIC_REGEX, + false); + String authorization = findFirst(metadata, config.authorizationPolicyOperationTypeRegex() != null + ? config.authorizationPolicyOperationTypeRegex() : DEFAULT_AUTHORIZATION_REGEX); + if (authorization != null) { + String cleaned = authorization.replaceAll("[((][^))]*[))]\\s*$", "").trim(); + if (!cleaned.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_AUTHORIZATION_POLICY_OPERATION_TYPE, cleaned); + } + } + return attrs; + } + + private static void putRegex(Map attrs, String key, Map metadata, + @Nullable String pattern, boolean unused) { + if (pattern == null || pattern.isEmpty()) { + return; + } + String found = findFirst(metadata, pattern); + if (found != null && !found.isEmpty()) { + attrs.put(key, found); + } + } + + private static @Nullable String findFirst(Map metadata, @Nullable String pattern) { + if (pattern == null || pattern.isEmpty()) { + return null; + } + Pattern compiled = Pattern.compile(pattern); + for (Object value : metadata.values()) { + if (value instanceof String s) { + Matcher matcher = compiled.matcher(s); + if (matcher.find()) { + return matcher.groupCount() > 0 ? matcher.group(1) : matcher.group(); + } + } + } + return null; + } +} +``` + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=AttributeExtractorsTest"`,期望 PASS。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add attribute constants and metadata extractors"` + +--- + +### Task 5: ObservabilityRuntime + +**Files:** +- Create: `.../observability/ObservabilityRuntime.java` +- Test: `.../observability/ObservabilityRuntimeTest.java` + +**Interfaces:** +- Consumes: Task 3 常量键名。 +- Produces: `isEnabled()` / `isTraceEnabled()` / `isMetricEnabled()` / `isLogEnabled()`(每次调用动态读 `System.getProperty` → `System.getenv`,无 import 期冻结——比 Python 可测性更好,行为等价);`tracer()` / `meter()`(`GlobalOpenTelemetry.getTracer/getMeter("a2at-observability", "1.0.0")`,未装 SDK 时 OTel API 天然 no-op);`sameTraceOrphanContext(@Nullable SpanContext) -> @Nullable Context`(同 trace 随机 span_id、无父、0 值守卫)。Task 6-10 与 Plan B 全部消费。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.api.trace.Span; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ObservabilityRuntimeTest { + + @AfterEach + void clearSwitches() { + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + System.clearProperty("A2AT_TRACE_ENABLED"); + } + + @Test + void masterSwitchDisablesAllSignals() { + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + assertFalse(ObservabilityRuntime.isEnabled()); + assertFalse(ObservabilityRuntime.isTraceEnabled()); + assertFalse(ObservabilityRuntime.isMetricEnabled()); + assertFalse(ObservabilityRuntime.isLogEnabled()); + } + + @Test + void traceToggleIsIndependentOfMasterOn() { + System.setProperty("A2AT_TRACE_ENABLED", "false"); + assertTrue(ObservabilityRuntime.isEnabled()); + assertFalse(ObservabilityRuntime.isTraceEnabled()); + assertTrue(ObservabilityRuntime.isMetricEnabled()); + } + + @Test + void sameTraceOrphanContextKeepsTraceIdAndDropsParent() { + SpanContext parent = SpanContext.create( + "0123456789abcdef0123456789abcdef", "0123456789abcdef", + TraceFlags.getSampled(), TraceState.getDefault()); + Context orphan = ObservabilityRuntime.sameTraceOrphanContext(parent); + assertNotNull(orphan); + SpanContext orphanSpan = Span.fromContext(orphan).getSpanContext(); + assertEquals(parent.getTraceId(), orphanSpan.getTraceId()); + assertNotEquals(parent.getSpanId(), orphanSpan.getSpanId()); + assertTrue(orphanSpan.isValid()); + } + + @Test + void invalidParentYieldsNull() { + assertNull(ObservabilityRuntime.sameTraceOrphanContext(SpanContext.getInvalid())); + assertNull(ObservabilityRuntime.sameTraceOrphanContext(null)); + } + + @Test + void tracerAndMeterScopeNames() { + try (Scope scope = Span.fromContext(Context.root()).makeCurrent()) { + assertNotNull(ObservabilityRuntime.tracer()); + assertNotNull(ObservabilityRuntime.meter()); + } + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=ObservabilityRuntimeTest"`。 + +- [ ] **Step 3: 实现** + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import java.util.concurrent.ThreadLocalRandom; +import org.jspecify.annotations.Nullable; + +/** Master/signal switches + OTel accessors. Switches read dynamically (property > env > default). */ +public final class ObservabilityRuntime { + + public static final String ENV_MASTER = "OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"; + public static final String ENV_TRACE = "A2AT_TRACE_ENABLED"; + public static final String ENV_METRIC = "A2AT_METRIC_ENABLED"; + public static final String ENV_LOG = "A2AT_LOG_ENABLED"; + + public static final String SCOPE_NAME = "a2at-observability"; + public static final String SCOPE_VERSION = "1.0.0"; + + private ObservabilityRuntime() {} + + private static boolean switchOn(String key) { + String raw = System.getProperty(key, System.getenv(key)); + if (raw == null) { + return true; + } + return switch (raw.trim().toLowerCase(java.util.Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + public static boolean isEnabled() { + return switchOn(ENV_MASTER); + } + + public static boolean isTraceEnabled() { + return isEnabled() && switchOn(ENV_TRACE); + } + + public static boolean isMetricEnabled() { + return isEnabled() && switchOn(ENV_METRIC); + } + + public static boolean isLogEnabled() { + return isEnabled() && switchOn(ENV_LOG); + } + + public static Tracer tracer() { + return GlobalOpenTelemetry.getTracer(SCOPE_NAME, SCOPE_VERSION); + } + + public static Meter meter() { + return GlobalOpenTelemetry.getMeter(SCOPE_NAME, SCOPE_VERSION); + } + + /** Same-trace orphan context: new span context shares trace_id, random span_id, no parent. */ + public static @Nullable Context sameTraceOrphanContext(@Nullable SpanContext parent) { + if (parent == null || !parent.isValid()) { + return null; + } + long random = ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE); + SpanContext orphan = SpanContext.create( + parent.getTraceId(), String.format("%016x", random), + TraceFlags.fromByte(parent.getTraceFlags().asByte()), parent.getTraceState()); + return Context.root().with(io.opentelemetry.api.trace.Span.wrap(orphan)); + } +} +``` + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=ObservabilityRuntimeTest"`。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add ObservabilityRuntime switches and orphan context"` + +--- + +### Task 6: A2ATSpan + A2ATCurrentSpan + CurrentSpans + +**Files:** +- Create: `.../observability/A2ATSpan.java`、`.../observability/A2ATCurrentSpan.java`、`.../observability/CurrentSpans.java` +- Test: `.../observability/A2ATSpanTest.java` + +**Interfaces:** +- Consumes: Task 5。 +- Produces: `A2ATSpan(String name)` / `A2ATSpan(String, SpanKind)`,链式 `setAttribute(String, Object)` / `addLink(A2ATSpan)`,`open() -> A2ATSpan`(启动并 makeCurrent;trace 关时吸收),`recordError(Throwable)`,`spanContext()`,`close()`(recordError→ERROR+record_exception,否则 OK;try-with-resources);`CurrentSpans.current() -> @Nullable A2ATCurrentSpan`;`A2ATCurrentSpan.traceId()/spanId()/isValid()/setAttribute(String,Object)`。手动 API 面合同(spec §9)。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class A2ATSpanTest { + + private InMemorySpanExporter exporter; + private SdkTracerProvider provider; + + @BeforeEach + void setUp() { + exporter = InMemorySpanExporter.create(); + provider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().setTracerProvider(provider).build()); + } + + @AfterEach + void tearDown() { + provider.close(); + GlobalOpenTelemetry.reset(); + } + + @Test + void tryWithResourcesEndsWithOk() { + try (A2ATSpan span = new A2ATSpan("manual.op", SpanKind.INTERNAL).setAttribute("k", "v").open()) { + span.setAttribute("k2", 2); + assertNotNull(CurrentSpans.current()); + assertNotNull(CurrentSpans.current().traceId()); + } + List ended = exporter.getFinishedSpanItems(); + assertEquals(1, ended.size()); + assertEquals("manual.op", ended.get(0).getName()); + assertEquals(io.opentelemetry.api.trace.StatusCode.OK, ended.get(0).getStatus().getStatusCode()); + assertEquals("v", ended.get(0).getAttributes().get( + io.opentelemetry.api.common.AttributeKey.stringKey("k"))); + } + + @Test + void recordErrorEndsWithErrorAndException() { + RuntimeException failure = new IllegalStateException("boom"); + try (A2ATSpan span = new A2ATSpan("manual.fail").open()) { + span.recordError(failure); + } + SpanData data = exporter.getFinishedSpanItems().get(0); + assertEquals(io.opentelemetry.api.trace.StatusCode.ERROR, data.getStatus().getStatusCode()); + assertFalse(data.getEvents().isEmpty()); + assertTrue(data.getStatus().getDescription().contains("boom")); + } + + @Test + void addLinkBeforeOpenCreatesLink() { + try (A2ATSpan entry = new A2ATSpan("entry").open()) { + try (A2ATSpan event = new A2ATSpan("event").addLink(entry).open()) { + // linked at creation + } + } + List ended = exporter.getFinishedSpanItems(); + SpanData event = ended.stream().filter(d -> d.getName().equals("event")).findFirst().orElseThrow(); + assertEquals(1, event.getLinks().size()); + assertEquals( + ended.stream().filter(d -> d.getName().equals("entry")).findFirst().orElseThrow() + .getSpanContext(), + event.getLinks().get(0).getSpanContext()); + } + + @Test + void singleUseAndClosedAbsorb() { + A2ATSpan span = new A2ATSpan("once").open(); + assertThrows(IllegalStateException.class, span::open); + span.close(); + span.close(); + } + + @Test + void currentSpanNullOutsideAndValidInside() { + assertNull(CurrentSpans.current()); + try (A2ATSpan span = new A2ATSpan("s").open()) { + A2ATCurrentSpan current = CurrentSpans.current(); + assertNotNull(current); + assertTrue(current.isValid()); + assertEquals(32, current.traceId().length()); + assertEquals(16, current.spanId().length()); + current.setAttribute("cur", "x"); + } + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=A2ATSpanTest"`。 + +- [ ] **Step 3: 实现** + +`A2ATSpan.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Scope; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Manual A2A-T span; try-with-resources + open(). Absorbs everything when trace is disabled. */ +public final class A2ATSpan implements AutoCloseable { + + private final String name; + private final SpanKind kind; + private final Map attributes = new LinkedHashMap<>(); + private final List pendingLinks = new ArrayList<>(); + private Span span; + private Scope scope; + private boolean opened; + private boolean closed; + private boolean errorRecorded; + + public A2ATSpan(String name) { + this(name, SpanKind.INTERNAL); + } + + public A2ATSpan(String name, SpanKind kind) { + this.name = name; + this.kind = kind; + } + + public A2ATSpan setAttribute(String key, Object value) { + if (span != null) { + applyAttribute(span, key, value); + } else { + attributes.put(key, value); + } + return this; + } + + public A2ATSpan addLink(A2ATSpan other) { + SpanContext context = other == null ? null : other.spanContext(); + if (context == null || !context.isValid()) { + return this; + } + if (span != null) { + span.addLink(context); + } else { + pendingLinks.add(context); + } + return this; + } + + /** Starts the span and makes it current; idempotent-guarded, absorbs when trace is off. */ + public A2ATSpan open() { + if (opened) { + throw new IllegalStateException("A2ATSpan is single-use; create a new instance for each span"); + } + opened = true; + if (!ObservabilityRuntime.isTraceEnabled()) { + return this; + } + SpanBuilder builder = ObservabilityRuntime.tracer().spanBuilder(name).setSpanKind(kind); + for (SpanContext link : pendingLinks) { + builder.addLink(link); + } + span = builder.startSpan(); + attributes.forEach(this::applyOnSpan); + scope = span.makeCurrent(); + return this; + } + + private void applyOnSpan(String key, Object value) { + if (span != null) { + applyAttribute(span, key, value); + } + } + + private static void applyAttribute(Span target, String key, Object value) { + switch (value) { + case String s -> target.setAttribute(key, s); + case Integer i -> target.setAttribute(key, i); + case Long l -> target.setAttribute(key, l); + case Boolean b -> target.setAttribute(key, b); + case Double d -> target.setAttribute(key, d); + case null, default -> target.setAttribute(key, String.valueOf(value)); + } + } + + public void recordError(Throwable error) { + if (span == null || error == null) { + return; + } + errorRecorded = true; + span.recordException(error); + span.setStatus(StatusCode.ERROR, String.valueOf(error.getMessage())); + } + + public SpanContext spanContext() { + return span != null ? span.getSpanContext() : SpanContext.getInvalid(); + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + try (Scope restore = scope) { + if (span != null && span.isRecording()) { + if (!errorRecorded) { + span.setStatus(StatusCode.OK); + } + span.end(); + } + } + } +} +``` + +注意:`errorRecorded` 守卫保证 `recordError` 后 `close` 不再覆盖为 OK(Python `__exit__(exc)` 语义的 Java 等价)。 + +`A2ATCurrentSpan.java` + `CurrentSpans.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; + +/** Read/append view of the active span. */ +public final class A2ATCurrentSpan { + private final Span span; + + A2ATCurrentSpan(Span span) { + this.span = span; + } + + public String traceId() { return span.getSpanContext().getTraceId(); } + public String spanId() { return span.getSpanContext().getSpanId(); } + public boolean isValid() { return span.getSpanContext().isValid(); } + public void setAttribute(String key, Object value) { span.setAttribute(key, String.valueOf(value)); } +} +``` + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; +import org.jspecify.annotations.Nullable; + +/** Accessor for the active A2A-T span. */ +public final class CurrentSpans { + + private CurrentSpans() {} + + public static @Nullable A2ATCurrentSpan current() { + Span span = Span.current(); + if (!span.getSpanContext().isValid() || !span.isRecording()) { + return null; + } + return new A2ATCurrentSpan(span); + } +} +``` + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=A2ATSpanTest"`。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add manual span API"` + +--- + +### Task 7: TracePropagation + PayloadExtractor + PayloadSanitizer + +**Files:** +- Create: `.../observability/TracePropagation.java`、`.../observability/PayloadExtractor.java`、`.../observability/PayloadSanitizer.java` +- Test: `.../observability/PropagationAndPayloadTest.java` + +**Interfaces:** +- Produces: `TracePropagation.TRACEPARENT_HEADER="traceparent"`;`inject(Map)`(当前上下文)/ `inject(Map, Context)`;`extract(Map) -> @Nullable Context`(key 小写容错;无效返回 null)。`PayloadExtractor.toJson(@Nullable Object) -> @Nullable String`(Jackson;失败 DEBUG+null)。`PayloadSanitizer.prepare(String, config)`(先脱敏后截断,`[truncated]` 后缀)。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class PropagationAndPayloadTest { + + @AfterEach + void reset() { + io.opentelemetry.api.GlobalOpenTelemetry.reset(); + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + } + + @Test + void injectExtractRoundtripSurvivesLowercasing() { + io.opentelemetry.api.GlobalOpenTelemetry.set(io.opentelemetry.sdk.OpenTelemetrySdk.builder() + .setTracerProvider(io.opentelemetry.sdk.trace.SdkTracerProvider.builder().build()).build()); + Map headers = new HashMap<>(); + String traceId; + try (var span = new A2ATSpan("root", io.opentelemetry.api.trace.SpanKind.CLIENT).open()) { + traceId = Span.current().getSpanContext().getTraceId(); + TracePropagation.inject(headers); + } + Map mixed = Map.of("TRACEPARENT", headers.get("traceparent")); + Context extracted = TracePropagation.extract(mixed); + org.junit.jupiter.api.Assertions.assertNotNull(extracted); + assertEquals(traceId, Span.fromContext(extracted).getSpanContext().getTraceId()); + assertTrue(headers.get("traceparent").startsWith("00-" + traceId)); + } + + @Test + void extractReturnsNullWhenInvalidOrDisabled() { + assertNull(TracePropagation.extract(Map.of("traceparent", "00-abc-def-01"))); + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + assertNull(TracePropagation.extract(Map.of("traceparent", "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"))); + } + + @Test + void payloadRedactsBeforeTruncation() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv().toBuilder() + .payloadLogMaxLength(10) + .payloadRedactor(value -> value.replace("secret", "***")) + .build(); + String prepared = PayloadSanitizer.prepare("secret-abcdefghijklmnopqrstuvwxyz", config); + assertFalse(prepared.contains("secret")); + assertTrue(prepared.endsWith("[truncated]")); + assertTrue(prepared.length() <= 10 + "[truncated]".length()); + } + + @Test + void payloadExtractorSerializesRecordsAndNullFails() { + record Point(int x, int y) {} + assertTrue(PayloadExtractor.toJson(new Point(1, 2)).contains("\"x\":1")); + assertNull(PayloadExtractor.toJson(null)); + assertNull(PayloadExtractor.toJson(new Object() { + @Override + public String toString() { + throw new IllegalStateException("unserializable"); + } + })); + } +} +``` + +注意:Jackson 序列化匿名类默认会抛 `InvalidDefinitionException` → `toJson` 捕获返回 null,正是断言路径。 + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=PropagationAndPayloadTest"`。 + +- [ ] **Step 3: 实现** + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapGetter; +import io.opentelemetry.context.propagation.TextMapSetter; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** W3C TraceContext propagation over plain header maps. */ +public final class TracePropagation { + + public static final String TRACEPARENT_HEADER = "traceparent"; + + private static final TextMapSetter> SETTER = Map::put; + private static final TextMapGetter> GETTER = new TextMapGetter<>() { + @Override + public Iterable keys(Map carrier) { + return carrier.keySet(); + } + + @Override + public @Nullable String get(@Nullable Map carrier, String key) { + return carrier == null ? null : carrier.get(key); + } + }; + + private TracePropagation() {} + + public static void inject(Map headers) { + if (headers == null || !ObservabilityRuntime.isEnabled()) { + return; + } + GlobalOpenTelemetry.getPropagators().getTextMapPropagator() + .inject(Context.current(), headers, SETTER); + } + + public static void inject(Map headers, Context context) { + if (headers == null || context == null || !ObservabilityRuntime.isEnabled()) { + return; + } + GlobalOpenTelemetry.getPropagators().getTextMapPropagator().inject(context, headers, SETTER); + } + + public static @Nullable Context extract(@Nullable Map headers) { + if (headers == null || !ObservabilityRuntime.isEnabled()) { + return null; + } + Map lowered = new HashMap<>(); + headers.forEach((key, value) -> lowered.put(key.toLowerCase(Locale.ROOT), value)); + Context extracted = GlobalOpenTelemetry.getPropagators().getTextMapPropagator() + .extract(Context.root(), lowered, GETTER); + SpanContextProbe probe = new SpanContextProbe(extracted); + return probe.isValid() ? extracted : null; + } + + private static final class SpanContextProbe { + private final boolean valid; + + SpanContextProbe(Context context) { + this.valid = io.opentelemetry.api.trace.Span.fromContext(context).getSpanContext().isValid(); + } + + boolean isValid() { + return valid; + } + } +} +``` + +(实现可内联为单行 `Span.fromContext(extracted).getSpanContext().isValid() ? extracted : null`,无需内部类——以简洁版为准。) + +```java +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Serializes request/event objects to JSON for payload channels. */ +public final class PayloadExtractor { + + private static final Logger LOG = LoggerFactory.getLogger(PayloadExtractor.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private PayloadExtractor() {} + + public static @Nullable String toJson(@Nullable Object value) { + if (value == null) { + return null; + } + try { + return MAPPER.writeValueAsString(value); + } catch (Exception e) { + LOG.debug("a2at: payload extraction failed", e); + return null; + } + } +} +``` + +```java +package net.openan.a2at.sdk.observability; + +import java.util.function.UnaryOperator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Shared payload preparation: redact first, then truncate (security order). */ +public final class PayloadSanitizer { + + private static final Logger LOG = LoggerFactory.getLogger(PayloadSanitizer.class); + + private PayloadSanitizer() {} + + public static String prepare(String payload, A2ATObservabilityConfig config) { + String value = payload; + UnaryOperator redactor = config.payloadRedactor(); + if (redactor != null) { + try { + value = redactor.apply(value); + } catch (RuntimeException e) { + LOG.warn("a2at: payload redactor failed; using unredacted value", e); + } + } + int max = config.payloadLogMaxLength(); + if (value.length() > max) { + return value.substring(0, max) + "[truncated]"; + } + return value; + } +} +``` + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=PropagationAndPayloadTest"`。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add propagation and payload channels"` + +--- + +### Task 8: LlmUsageStash + ObservabilityLLMClient + MetricHistograms + NegotiationMetrics + +**Files:** +- Create: `.../observability/LlmUsageStash.java`、`.../observability/LlmUsageStashHolder.java`、`.../observability/MetricHistograms.java`、`.../observability/NegotiationMetrics.java`、`.../observability/ObservabilityLLMClient.java` +- Test: `.../observability/LlmObservabilityTest.java`、`.../observability/NegotiationMetricsTest.java` + +**Interfaces:** +- Consumes: `LLMClient`/`LLMResponse`(a2a-t-llm;`usage` 键 `prompt_tokens`/`completion_tokens`)、Task 4 常量、Task 5 开关。 +- Produces: + - `record LlmUsageStash(@Nullable Integer inputTokens, @Nullable Integer outputTokens, @Nullable String requestModel)` + - `LlmUsageStashHolder.set(stash)` / `getAndClear()` / `clear()`(ThreadLocal) + - `ObservabilityLLMClient implements LLMClient`(包装 delegate;调用后 stash + 记 `gen_ai.client.token.usage` 直方图,`gen_ai.token.type`=input/output;metric 关时仅 stash) + - `MetricHistograms.histogram(name, unit, description) -> LongHistogram`(ConcurrentHashMap 惰性缓存;package-private) + - `NegotiationMetrics.reportRounds(@Nullable String negotiationId, @Nullable String outcome, int rounds)`(outcome ∈ {accept,reject,abort};`extension.name`+`negotiation.id`+`outcome` 属性;metric 关 → 跳过;永不抛) + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.PeriodicExportingMetricReader; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LlmObservabilityTest { + + private InMemoryMetricReader reader; + + @BeforeEach + void setUp() { + reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder() + .registerMetricReader(reader) + .build(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().setMeterProvider(provider).build()); + } + + @AfterEach + void tearDown() { + GlobalOpenTelemetry.reset(); + } + + @Test + void structuredStashesUsageAndRecordsTokenHistogram() { + LLMClient scripted = (messages, schema, temperature, maxTokens) -> new LLMResponse( + "ok", "gpt-x", Map.of("prompt_tokens", 10, "completion_tokens", 5), Map.of()); + ObservabilityLLMClient client = new ObservabilityLLMClient(scripted); + client.structured(List.of(Map.of("role", "user", "content", "hi")), Map.of(), null, null); + + LlmUsageStash stash = LlmUsageStashHolder.getAndClear(); + assertNotNull(stash); + assertEquals(10, stash.inputTokens()); + assertEquals(5, stash.outputTokens()); + assertEquals("gpt-x", stash.requestModel()); + assertNull(LlmUsageStashHolder.getAndClear()); + + var metrics = reader.collectAllMetrics().join(); + assertTrue(metrics.stream().anyMatch(m -> "gen_ai.client.token.usage".equals(m.getName()) + && !m.getHistogramData().getDataPoints().isEmpty()), + "token usage histogram must be recorded"); + } + + @Test + void metricDisabledStillStashes() { + System.setProperty("A2AT_METRIC_ENABLED", "false"); + try { + LLMClient scripted = (messages, schema, temperature, maxTokens) -> + new LLMResponse("ok", "m", Map.of("prompt_tokens", 1, "completion_tokens", 2), Map.of()); + new ObservabilityLLMClient(scripted).structured(List.of(), Map.of(), null, null); + assertNotNull(LlmUsageStashHolder.getAndClear()); + assertEquals(0, reader.collectAllMetrics().join().size()); + } finally { + System.clearProperty("A2AT_METRIC_ENABLED"); + } + } + + @Test + void stashFailurePathKeepsSemantics() { + // delegate 异常原样抛出,不写 stash(观测永不破坏业务) + LLMClient throwing = (messages, schema, temperature, maxTokens) -> { + throw new IllegalStateException("llm down"); + }; + org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, + () -> new ObservabilityLLMClient(throwing).structured(List.of(), Map.of(), null, null)); + assertNull(LlmUsageStashHolder.getAndClear()); + } +} +``` + +注意:`LLMResponse.usage` 类型为 `Map`(键 `prompt_tokens`/`completion_tokens`,见 `a2a-t-llm` 的 `OpenAIClient`),类型约束由编译器保证,无需非法值用例。 + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class NegotiationMetricsTest { + + private InMemoryMetricReader reader; + + @BeforeEach + void setUp() { + reader = InMemoryMetricReader.create(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder() + .setMeterProvider(SdkMeterProvider.builder().registerMetricReader(reader).build()).build()); + } + + @AfterEach + void tearDown() { + GlobalOpenTelemetry.reset(); + } + + @Test + void terminalOutcomeRecordsRoundsWithAttributes() { + NegotiationMetrics.reportRounds("N001", "accept", 3); + var metrics = reader.collectAllMetrics().join(); + var histogram = metrics.stream() + .filter(m -> "a2at.negotiation.total_rounds".equals(m.getName())) + .findFirst().orElseThrow(); + assertEquals("a2at.negotiation.total_rounds", histogram.getName()); + assertTrue(!histogram.getHistogramData().getDataPoints().isEmpty()); + } + + @Test + void nonTerminalOutcomeAndNegativeRoundsAreSkipped() { + NegotiationMetrics.reportRounds("N1", "propose", 2); + NegotiationMetrics.reportRounds("N1", "accept", -1); + assertEquals(0, reader.collectAllMetrics().join().size()); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=LlmObservabilityTest,NegotiationMetricsTest"`。 + +> 依赖补充:`InMemoryMetricReader` 来自 `io.opentelemetry:opentelemetry-sdk-testing:1.64.0` —— 在 `a2a-t-observability/pom.xml` 增加测试依赖(版本经 bom 管理): +> ```xml +> +> io.opentelemetry +> opentelemetry-sdk-testing +> test +> +> ``` + +- [ ] **Step 3: 实现** + +`LlmUsageStash.java` + `LlmUsageStashHolder.java`: + +```java +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.Nullable; + +/** Token usage captured from one LLM call; drained onto the next entry span. */ +public record LlmUsageStash( + @Nullable Integer inputTokens, + @Nullable Integer outputTokens, + @Nullable String requestModel) {} +``` + +```java +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.Nullable; + +/** ThreadLocal bridge: LLM layer -> entry span (SDK is fully synchronous). */ +public final class LlmUsageStashHolder { + + private static final ThreadLocal STASH = new ThreadLocal<>(); + + private LlmUsageStashHolder() {} + + public static void set(LlmUsageStash usage) { + STASH.set(usage); + } + + public static @Nullable LlmUsageStash getAndClear() { + LlmUsageStash stash = STASH.get(); + STASH.remove(); + return stash; + } + + public static void clear() { + STASH.remove(); + } +} +``` + +`MetricHistograms.java`(package-private): + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.metrics.LongHistogram; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** Lazy histogram cache keyed by metric name (shared meter scope a2at-observability/1.0.0). */ +final class MetricHistograms { + + private static final ConcurrentMap INSTRUMENTS = new ConcurrentHashMap<>(); + + private MetricHistograms() {} + + static LongHistogram histogram(String name, String unit, String description) { + return INSTRUMENTS.computeIfAbsent(name, metricName -> ObservabilityRuntime.meter() + .histogramBuilder(metricName) + .setUnit(unit) + .setDescription(description) + .ofLongs() + .build()); + } +} +``` + +`NegotiationMetrics.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.common.Attributes; +import java.util.Set; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** a2at.negotiation.total_rounds histogram; terminal outcomes only; never raises. */ +public final class NegotiationMetrics { + + public static final String METRIC_NAME = "a2at.negotiation.total_rounds"; + private static final Logger LOG = LoggerFactory.getLogger(NegotiationMetrics.class); + private static final Set OUTCOMES = Set.of("accept", "reject", "abort"); + + private NegotiationMetrics() {} + + public static void reportRounds(@Nullable String negotiationId, @Nullable String outcome, int rounds) { + try { + if (!ObservabilityRuntime.isMetricEnabled() || rounds < 0) { + return; + } + if (outcome == null || !OUTCOMES.contains(outcome.toLowerCase(java.util.Locale.ROOT))) { + return; + } + var builder = Attributes.builder(); + builder.put(A2ATAttributes.ATTR_EXTENSION_NAME, A2ATAttributes.NEGOTIATION_EXTENSION); + if (negotiationId != null && !negotiationId.isEmpty()) { + builder.put(A2ATAttributes.ATTR_NEGOTIATION_ID, negotiationId); + } + builder.put("outcome", outcome.toLowerCase(java.util.Locale.ROOT)); + MetricHistograms.histogram(METRIC_NAME, "{round}", "Negotiation terminal message round count") + .record(rounds, builder.build()); + } catch (RuntimeException e) { + LOG.warn("a2at: failed to report negotiation rounds", e); + } + } +} +``` + +`ObservabilityLLMClient.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.common.Attributes; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** LLMClient decorator: usage stash + gen_ai.client.token.usage histogram; never breaks the call. */ +public final class ObservabilityLLMClient implements LLMClient { + + public static final String METRIC_TOKEN_USAGE = "gen_ai.client.token.usage"; + private static final Logger LOG = LoggerFactory.getLogger(ObservabilityLLMClient.class); + private final LLMClient delegate; + + public ObservabilityLLMClient(LLMClient delegate) { + this.delegate = java.util.Objects.requireNonNull(delegate); + } + + @Override + public LLMResponse structured( + List> messages, Map jsonSchema, + @Nullable Double temperature, @Nullable Integer maxTokens) { + LLMResponse response = delegate.structured(messages, jsonSchema, temperature, maxTokens); + report(response); + return response; + } + + private void report(LLMResponse response) { + try { + Map usage = response.usage() == null ? Map.of() : response.usage(); + Integer input = usage.get("prompt_tokens"); + Integer output = usage.get("completion_tokens"); + LlmUsageStashHolder.set(new LlmUsageStash(input, output, response.model())); + if (ObservabilityRuntime.isMetricEnabled()) { + recordTokenUsage(input, output); + } + } catch (RuntimeException e) { + LOG.warn("a2at: failed to report llm token usage", e); + } + } + + private void recordTokenUsage(@Nullable Integer input, @Nullable Integer output) { + if (input != null) { + MetricHistograms.histogram(METRIC_TOKEN_USAGE, "{token}", "GenAI client token usage") + .record(input, Attributes.of(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE, "input")); + } + if (output != null) { + MetricHistograms.histogram(METRIC_TOKEN_USAGE, "{token}", "GenAI client token usage") + .record(output, Attributes.of(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE, "output")); + } + } +} +``` + +(`LongHistogram unused` 残行已清理——以上码即最终版。) + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=LlmObservabilityTest,NegotiationMetricsTest"`。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add LLM usage stash, token metric and negotiation rounds metric"` + +--- + +### Task 9: ObservabilityLogs + ConsoleExporters + ObservabilitySetup + +**Files:** +- Create: `.../observability/ObservabilityLogs.java`、`.../observability/ConsoleExporters.java`、`.../observability/ObservabilitySetup.java` +- Test: `.../observability/ObservabilityLogsTest.java`、`.../observability/ObservabilitySetupTest.java` + +**Interfaces:** +- Consumes: Task 3 config、Task 5 开关、Task 7 sanitizer。 +- Produces: + - `ObservabilityLogs.logEvent(String event, org.slf4j.event.Level level, Map fields, @Nullable String payload, A2ATObservabilityConfig config)`:SLF4J JSON 行(logger `net.openan.a2at.sdk.observability`,字段 event + fields + trace_id/span_id + payload?)+ OTel `Logger` API 同步发射(`GlobalLoggerProvider.get().get("a2at-observability")`);log_enabled 双门控;永不抛。 + - `ConsoleExporters`(package-private):`spanExporter()` / `metricExporter()` / `logRecordExporter()` —— 内置 SLF4J JSON 导出器(`opentelemetry-exporter-logging` 不在 .m2,等效自研)。 + - `ObservabilitySetup.ensureConfigured()` / `ensureConfigured(A2ATObservabilityConfig)` / `isConfigured()`:懒装配三信号(SdkTracerProvider/SdkMeterProvider/SdkLoggerProvider + Batch* + PeriodicExportingMetricReader);endpoint → OTLP gRPC/HTTP(HTTP 拼 `/v1/traces|metrics|logs`);无 endpoint → Console(`A2AT_CONSOLE_EXPORTER=false` 且无 endpoint → 信号保持 no-op);幂等(once 标志 + `GlobalOpenTelemetry.set` 抛 `IllegalStateException` 捕获跳过);sdk 类缺失 `Class.forName` 探测跳过。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.api.GlobalOpenTelemetry; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +class ObservabilityLogsTest { + + private ListAppender appender; + private Logger logger; + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void attach() { + logger = (Logger) LoggerFactory.getLogger("net.openan.a2at.sdk.observability"); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(ch.qos.logback.classic.Level.DEBUG); + GlobalOpenTelemetry.reset(); + } + + @AfterEach + void detach() { + logger.detachAppender(appender); + System.clearProperty("A2AT_LOG_ENABLED"); + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + } + + @Test + void jsonRecordCarriesEventFieldsAndTraceIds() throws Exception { + try (A2ATSpan span = new A2ATSpan("entry").open()) { + ObservabilityLogs.logEvent("task.status_changed", Level.INFO, + Map.of("task.id", "t1", "task.status", "working"), null, A2ATObservabilityConfig.fromEnv()); + } + ILoggingEvent event = appender.list.stream() + .filter(e -> e.getLevel() == Level.INFO).findFirst().orElseThrow(); + Map json = mapper.readValue(event.getFormattedMessage(), Map.class); + assertEquals("task.status_changed", json.get("event")); + assertEquals("t1", json.get("task.id")); + assertEquals("working", json.get("task.status")); + assertNotNull(json.get("trace_id")); + assertNotNull(json.get("span_id")); + } + + @Test + void payloadChannelHonorsConfigAndRedaction() throws Exception { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv().toBuilder() + .payloadLogEnabled(true) + .payloadRedactor(v -> v.replace("key", "***")) + .build(); + ObservabilityLogs.logEvent("task.request", Level.DEBUG, Map.of(), "payload: key=1", config); + Map json = mapper.readValue(appender.list.get(0).getFormattedMessage(), Map.class); + assertTrue(String.valueOf(json.get("payload")).contains("***")); + assertFalse(String.valueOf(json.get("payload")).contains("key=1")); + } + + @Test + void logSwitchOffEmitsNothing() { + System.setProperty("A2AT_LOG_ENABLED", "false"); + ObservabilityLogs.logEvent("task.request", Level.DEBUG, Map.of(), null, A2ATObservabilityConfig.fromEnv()); + System.clearProperty("A2AT_LOG_ENABLED"); + assertEquals(0, appender.list.size()); + } +} +``` + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ObservabilitySetupTest { + + @AfterEach + void reset() { + GlobalOpenTelemetry.reset(); + System.clearProperty("A2AT_OBSERVABILITY_ENABLED"); + } + + @Test + void consoleFallbackConfiguresAllThreeSignals() { + GlobalOpenTelemetry.reset(); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertTrue(ObservabilitySetup.isConfigured()); + assertInstanceOf(OpenTelemetrySdk.class, GlobalOpenTelemetry.get()); + GlobalOpenTelemetry.getTracer("t").spanBuilder("s").startSpan().end(); + } + + @Test + void existingGlobalProviderIsRespected() { + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().build()); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertFalse(ObservabilitySetup.isConfigured()); + } + + @Test + void disabledSkipsEverything() { + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertFalse(ObservabilitySetup.isConfigured()); + assertFalse(ObservabilityRuntime.isEnabled()); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=ObservabilityLogsTest,ObservabilitySetupTest"`。 + +- [ ] **Step 3: 实现** + +`ObservabilityLogs.java`: + +```java +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.logs.SeverityNumber; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.slf4j.event.Level; + +/** Structured JSON events: SLF4J channel + OTel logs channel sharing identical semantics. */ +public final class ObservabilityLogs { + + public static final String LOGGER_NAME = "net.openan.a2at.sdk.observability"; + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(LOGGER_NAME); + private static final Logger OTEL_LOGGER = io.opentelemetry.api.logs.GlobalLoggerProvider.get() + .get(ObservabilityRuntime.SCOPE_NAME); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ObservabilityLogs() {} + + public static void logEvent(String event, Level level, Map fields, + @Nullable String payload, A2ATObservabilityConfig config) { + if (!ObservabilityRuntime.isLogEnabled() || !config.logEnabled()) { + return; + } + try { + Map data = new LinkedHashMap<>(); + data.put("event", event); + if (fields != null) { + data.putAll(fields); + } + var spanContext = io.opentelemetry.api.trace.Span.current().getSpanContext(); + if (spanContext.isValid()) { + data.put("trace_id", spanContext.getTraceId()); + data.put("span_id", spanContext.getSpanId()); + } + if (payload != null && config.payloadLogEnabled()) { + data.put("payload", PayloadSanitizer.prepare(payload, config)); + } + String json = MAPPER.writeValueAsString(data); + LOG.atLevel(level).log(json); + emitOtel(level, data, json); + } catch (Exception e) { + LOG.debug("a2at: log_event failed", e); + } + } + + private static void emitOtel(Level level, Map data, String json) { + try { + var builder = OTEL_LOGGER.logRecordBuilder() + .setSeverity(severity(level)) + .setSeverityText(level.name()) + .setBody(json); + data.forEach((key, value) -> { + if (!"event".equals(key) && !"trace_id".equals(key) && !"span_id".equals(key)) { + builder.setAttribute(key, String.valueOf(value)); + } + }); + builder.emit(); + } catch (RuntimeException e) { + LOG.debug("a2at: otel log emit failed", e); + } + } + + private static SeverityNumber severity(Level level) { + return switch (level) { + case DEBUG -> SeverityNumber.DEBUG; + case INFO -> SeverityNumber.INFO; + case WARN -> SeverityNumber.WARN; + case ERROR -> SeverityNumber.ERROR; + default -> SeverityNumber.INFO; + }; + } +} +``` + +`ConsoleExporters.java`(package-private,先 `javap -cp io.opentelemetry.sdk.metrics.export.MetricExporter io.opentelemetry.sdk.logs.export.LogRecordExporter io.opentelemetry.sdk.trace.export.SpanExporter` 核对 1.64 方法签名,若与下码不符按实际调整): + +```java +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.logs.export.LogRecordExporter; +import io.opentelemetry.sdk.metrics.InstrumentType; +import io.opentelemetry.sdk.metrics.data.AggregationTemporality; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Collection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** In-house dev-mode exporters (JSON lines on SLF4J); replaces opentelemetry-exporter-logging. */ +final class ConsoleExporters { + + private static final Logger LOG = LoggerFactory.getLogger("net.openan.a2at.sdk.observability.console"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ConsoleExporters() {} + + static SpanExporter spanExporter() { + return new SpanExporter() { + @Override + public CompletableResultCode export(Collection spans) { + spans.forEach(span -> LOG.info(json("span", span.getName(), + java.util.Map.of( + "traceId", span.getTraceId(), "spanId", span.getSpanId(), + "kind", span.getKind().name(), + "status", span.getStatus().getStatusCode().name(), + "attributes", span.getAttributes().asMap())))); + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.success(); + } + }; + } + + static MetricExporter metricExporter() { + return new MetricExporter() { + @Override + public CompletableResultCode export(Collection metrics) { + metrics.forEach(metric -> LOG.info(json("metric", metric.getName(), + java.util.Map.of("unit", String.valueOf(metric.getUnit()), + "description", String.valueOf(metric.getDescription()))))); + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.success(); + } + + @Override + public AggregationTemporality getAggregationTemporality(InstrumentType instrumentType) { + return AggregationTemporality.CUMULATIVE; + } + }; + } + + static LogRecordExporter logRecordExporter() { + return new LogRecordExporter() { + @Override + public CompletableResultCode export(Collection records) { + records.forEach(record -> LOG.info(json("log", String.valueOf(record.getBodyValue()), + java.util.Map.of( + "severity", String.valueOf(record.getSeverityText()), + "attributes", record.getAttributes().asMap())))); + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.success(); + } + }; + } + + private static String json(String signal, String name, Object data) { + try { + return MAPPER.writeValueAsString(java.util.Map.of("signal", signal, "name", name, "data", data)); + } catch (Exception e) { + return "{\"signal\":\"" + signal + "\",\"error\":\"serialize_failed\"}"; + } + } +} +``` + +`ObservabilitySetup.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.logs.OtlpGrpcLogRecordExporter; +import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor; +import io.opentelemetry.sdk.metrics.PeriodicExportingMetricReader; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Lazy auto-config of all three signals; user-registered providers are never overridden. */ +public final class ObservabilitySetup { + + private static final Logger LOG = LoggerFactory.getLogger(ObservabilitySetup.class); + private static final AtomicBoolean CONFIGURED = new AtomicBoolean(false); + + private ObservabilitySetup() {} + + public static boolean isConfigured() { + return CONFIGURED.get(); + } + + public static void ensureConfigured() { + ensureConfigured(A2ATObservabilityConfig.fromEnv()); + } + + public static void ensureConfigured(A2ATObservabilityConfig config) { + if (CONFIGURED.get() || !ObservabilityRuntime.isEnabled() || !config.enabled()) { + return; + } + if (!sdkAvailable()) { + return; + } + try { + configure(config); + CONFIGURED.set(true); + } catch (IllegalStateException alreadySet) { + LOG.debug("a2at: global OTel already registered; skipping auto-config"); + } catch (RuntimeException e) { + LOG.warn("a2at: otel auto-config failed", e); + } + } + + private static boolean sdkAvailable() { + try { + Class.forName("io.opentelemetry.sdk.OpenTelemetrySdk"); + return true; + } catch (ClassNotFoundException e) { + LOG.warn("a2at: opentelemetry-sdk not on classpath; auto-config skipped"); + return false; + } + } + + private static void configure(A2ATObservabilityConfig config) { + Resource resource = buildResource(config); + Exporters exporters = buildExporters(config); + SdkTracerProvider tracerProvider = null; + SdkMeterProvider meterProvider = null; + SdkLoggerProvider loggerProvider = null; + if (exporters.span != null && config.traceEnabled() && ObservabilityRuntime.isTraceEnabled()) { + tracerProvider = SdkTracerProvider.builder() + .setResource(resource) + .addSpanProcessor(BatchSpanProcessor.builder(exporters.span) + .setExporterTimeout(2, TimeUnit.SECONDS).build()) + .build(); + } + if (exporters.metric != null && config.metricEnabled() && ObservabilityRuntime.isMetricEnabled()) { + meterProvider = SdkMeterProvider.builder() + .setResource(resource) + .registerMetricReader(PeriodicExportingMetricReader.builder(exporters.metric) + .setInterval(30, TimeUnit.SECONDS).build()) + .build(); + } + if (exporters.log != null && config.logEnabled() && ObservabilityRuntime.isLogEnabled()) { + loggerProvider = SdkLoggerProvider.builder() + .setResource(resource) + .addLogRecordProcessor(BatchLogRecordProcessor.builder(exporters.log).build()) + .build(); + } + OpenTelemetrySdk.Builder builder = OpenTelemetrySdk.builder(); + if (tracerProvider != null) { + builder.setTracerProvider(tracerProvider); + } + if (meterProvider != null) { + builder.setMeterProvider(meterProvider); + } + if (loggerProvider != null) { + builder.setLoggerProvider(loggerProvider); + } + GlobalOpenTelemetry.set(builder.build()); + if (exporters.console) { + LOG.warn("a2at: no OTLP endpoint configured; using Console exporters (dev mode)"); + } + } + + private static Resource buildResource(A2ATObservabilityConfig config) { + Attributes.Builder attributes = Attributes.builder(); + attributes.put("service.name", config.serviceName() == null + ? A2ATObservabilityConfig.DEFAULT_SERVICE_NAME : config.serviceName()); + String version = ObservabilitySetup.class.getPackage() == null + ? null : ObservabilitySetup.class.getPackage().getImplementationVersion(); + attributes.put("service.version", version == null ? "dev" : version); + if (config.resolveAgentRole() != null) { + attributes.put("agent.role", config.resolveAgentRole()); + } + return Resource.getDefault().merge(Resource.create(attributes.build())); + } + + private static Exporters buildExporters(A2ATObservabilityConfig config) { + Exporters exporters = new Exporters(); + if (config.endpoint() == null) { + boolean consoleEnabled = switchOn(A2ATObservabilityConfig.ENV_CONSOLE_EXPORTER); + if (!consoleEnabled) { + LOG.info("a2at: console exporter disabled and no OTLP endpoint; signals stay no-op"); + return exporters; + } + exporters.span = ConsoleExporters.spanExporter(); + exporters.metric = ConsoleExporters.metricExporter(); + exporters.log = ConsoleExporters.logRecordExporter(); + exporters.console = true; + return exporters; + } + exporters.console = false; + if ("http/protobuf".equals(config.protocol())) { + String base = config.endpoint().replaceAll("/$", ""); + exporters.span = OtlpHttpSpanExporter.builder() + .setEndpoint(base + "/v1/traces").build(); + exporters.metric = OtlpHttpMetricExporter.builder() + .setEndpoint(base + "/v1/metrics").build(); + exporters.log = OtlpHttpLogRecordExporter.builder() + .setEndpoint(base + "/v1/logs").build(); + } else { + exporters.span = OtlpGrpcSpanExporter.builder() + .setEndpoint(config.endpoint()).build(); + exporters.metric = OtlpGrpcMetricExporter.builder() + .setEndpoint(config.endpoint()).build(); + exporters.log = OtlpGrpcLogRecordExporter.builder() + .setEndpoint(config.endpoint()).build(); + } + return exporters; + } + + private static boolean switchOn(String key) { + String raw = System.getProperty(key, System.getenv(key)); + if (raw == null) { + return true; + } + return switch (raw.trim().toLowerCase(java.util.Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + private static final class Exporters { + @Nullable SpanExporter span; + @Nullable MetricExporter metric; + @Nullable LogRecordExporter log; + boolean console; + } +} +``` + +(`Exporters` 为可变静态内部类——record 不可变与逐字段赋值冲突,故不用 record。) + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=ObservabilityLogsTest,ObservabilitySetupTest"`(若 OTLP exporter builder API 与 1.64 签名不符,按 javap 调整;console 路径不依赖 OTLP 类加载——但 configure 在 endpoint=null 时不触碰 OTLP 类,`Class.forName` 保护仅在 sdkAvailable 一层,OTLP 类加载发生在 buildExporters 的 http/grpc 分支,endpoint=null 不会加载 ✓)。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add structured logs, console exporters and OTel auto-setup"` + +--- + +### Task 10: 门面接线(A2ATClient / A2ATServer)+ env.example + 全量回归 + +**Files:** +- Modify: `a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java` +- Modify: `a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java` +- Modify: `env.example`(追加可观测性小节) +- Test: `a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java`、`a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java` + +**Interfaces:** +- Consumes: Task 2 `TelemetryHook`/`TelemetryOperation`。 +- Produces: + - `A2ATClient(Path envPath, @Nullable TelemetryHook hook)` 新二级构造器;既有 `A2ATClient(Path)` 委托 `NO_OP`。字段 `private final TelemetryHook hook;`。 + - `A2ATServer(Path envPath, @Nullable TelemetryHook hook)` 同构。 + - 包私有 `static @Nullable String terminalOutcome(NegotiationStatus status)`:`AGREED→"accept"`、`REJECTED→"reject"`、其余 null(client/server 各一份 4 行副本,因 observability 模块不依赖 negotiation)。 + - 门面全部公开方法经 `observed(...)` 包 Span:名 `a2at.sdk.client.{method}` / `a2at.sdk.server.{method}`;成功 `markSuccess()`,RuntimeException 路径 `markError` + 原样抛。 + - `continueNegotiation(NegotiationContext, NegotiationStatus, String)` 成功后按 `terminalOutcome(status)` 回调 `hook.reportNegotiationRounds(context.negotiationId(), outcome, context.round())`。 + +**方法清单(必须全部包装,逐个修改,不得遗漏):** +- client(24):`generateTaskPrompt`、`generateTaskPromptFromText`、`generateTaskPromptFromDataWithSchema`、`generateAuthPromptFromText`、`generateAuthPromptFromDataWithSchema`、`generateNotificationPromptFromText`、`generateNotificationPromptFromDataWithSchema`、`startNegotiation`、`receiveNegotiation`、`continueNegotiation`、`generateNegotiationProposePromptFromData`、`generateNegotiationAcceptPromptFromData`、`generateNegotiationRejectPromptFromData`、`generateNegotiationAbortPromptFromData`、`generateNegotiationProposePromptFromText`、`generateNegotiationAcceptPromptFromText`、`generateNegotiationRejectPromptFromText`、`generateNegotiationAbortPromptFromText`、`getPrompts`、`getPrompt`、`validateProposePromptAndDataFilling`、`validateAcceptPromptAndDataFilling`、`validateRejectPromptAndDataFilling`、`validateAbortPromptAndDataFilling` +- server(21):`checkTaskPrompt`、`startNegotiation`、`receiveNegotiation`、`continueNegotiation`、`generateNegotiationProposePromptFromData`、`generateNegotiationAcceptPromptFromData`、`generateNegotiationRejectPromptFromData`、`generateNegotiationAbortPromptFromData`、`generateNegotiationProposePromptFromText`、`generateNegotiationAcceptPromptFromText`、`generateNegotiationRejectPromptFromText`、`generateNegotiationAbortPromptFromText`、`getPrompts`、`getPrompt`、`validateProposePromptAndDataFilling`、`validateAcceptPromptAndDataFilling`、`validateRejectPromptAndDataFilling`、`validateAbortPromptAndDataFilling`、`validateTaskPromptAndDataFilling`、`validateNotificationPromptAndDataFilling`、`validateAuthPromptAndDataFilling` + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.client.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class A2ATClientObservabilityTest { + + private static final String MOCK_PROVIDER = "a2at-observability-mock"; + + static final class NoopLLM implements LLMClient { + @Override + public LLMResponse structured( + List> messages, + java.util.Map schema, Double temperature, Integer maxTokens) { + return new LLMResponse("{}", "mock", java.util.Map.of(), java.util.Map.of()); + } + } + + static final class RecordingHook implements TelemetryHook { + final List started = new ArrayList<>(); + final List succeeded = new ArrayList<>(); + final List rounds = new ArrayList<>(); + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + String name = role + "." + method; + started.add(name); + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + @Override public void markSuccess() { succeeded.add(name); } + @Override public void markError(Throwable error) { started.add("FAILED:" + name); } + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + } + + @TempDir + Path tempDir; + + private A2ATClient newClient(TelemetryHook hook) throws IOException { + if (!LLMClientFactory.availableProviders().contains(MOCK_PROVIDER)) { + LLMClientFactory.register(MOCK_PROVIDER, NoopLLM.class); + } + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """.formatted(MOCK_PROVIDER)); + return new A2ATClient(envFile, hook); + } + + @Test + void llmFreePathIsWrappedAsSdkOperation() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATClient client = newClient(hook); + client.getPrompts(); + assertTrue(hook.started.contains("client.getPrompts")); + assertTrue(hook.succeeded.contains("client.getPrompts")); + } + + @Test + void runtimeFailureMarksErrorAndPropagates() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATClient client = newClient(hook); + assertThrows(NullPointerException.class, () -> client.generateTaskPromptFromText(null, null)); + assertTrue(hook.started.contains("client.generateTaskPromptFromText")); + assertTrue(hook.started.contains("FAILED:client.generateTaskPromptFromText")); + } + + @Test + void legacyConstructorUsesNoOpHook() throws IOException { + A2ATClient client = newClient(TelemetryHook.NO_OP); + assertEquals(client.getPrompts().size(), client.getPrompts().size()); + } + + @Test + void terminalOutcomeMapping() { + assertEquals("accept", A2ATClient.terminalOutcome(NegotiationStatus.AGREED)); + assertEquals("reject", A2ATClient.terminalOutcome(NegotiationStatus.REJECTED)); + assertEquals(null, A2ATClient.terminalOutcome(NegotiationStatus.IN_PROGRESS)); + } +} +``` + +server 测试 `A2ATServerObservabilityTest`:镜像以上结构(fixture env 同形、`new A2ATServer(env, hook)`、断言 `server.getPrompts`/`server.checkTaskPrompt` 起止、`A2ATServer.terminalOutcome` 映射)。server 包下既有测试(`A2ATServerNegotiationApiTest` 等)已有同型 env fixture 可参照。 + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-client test "-Dtest=A2ATClientObservabilityTest"`(编译错误:二级构造器/terminalOutcome 不存在)。 + +- [ ] **Step 3: 实现(A2ATClient)** + +1) 导入 + 字段 + 构造器改造: + +```java +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import org.jspecify.annotations.Nullable; + +public final class A2ATClient { + private final ClientPromptGenerationOrchestrator promptGenerationOrchestrator; + private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; + private final NegotiationContentService negotiationContentService; + private final TemplateQueryService templateQueryService; + private final TelemetryHook hook; + + public A2ATClient(Path envPath) { + this(envPath, null); + } + + public A2ATClient(Path envPath, @Nullable TelemetryHook hook) { + Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); + A2ATConfig config = + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + DefaultA2ATClientBuilder builder = + DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); + this.hook = hook == null ? TelemetryHook.NO_OP : hook; + this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); + this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); + this.negotiationContentService = + new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.templateQueryService = builder.buildTemplateQueryService(); + } + + private T observed(String method, java.util.function.Supplier action) { + try (TelemetryOperation operation = hook.startSdkOperation("client", method)) { + try { + T result = action.get(); + operation.markSuccess(); + return result; + } catch (RuntimeException error) { + operation.markError(error); + throw error; + } + } + } + + static @Nullable String terminalOutcome(NegotiationStatus status) { + if (status == NegotiationStatus.AGREED) { + return "accept"; + } + return status == NegotiationStatus.REJECTED ? "reject" : null; + } + + private void reportNegotiationRounds(NegotiationContext context, NegotiationStatus status) { + try { + String outcome = terminalOutcome(status); + if (outcome != null) { + hook.reportNegotiationRounds(context.negotiationId(), outcome, context.round()); + } + } catch (RuntimeException error) { + LOGGER.warn("a2at: negotiation metric report failed", error); + } + } +} +``` + +(client 现无 LOGGER 则加 `private static final org.slf4j.Logger LOGGER = org.slf4j.LoggerFactory.getLogger(A2ATClient.class);`) + +2) 方法包装(保持既有方法体为 lambda 载荷,逐个改写;三个代表样例): + +```java +public PromptGenerationResult generateTaskPrompt(Object userInput) { + return observed("generateTaskPrompt", () -> promptGenerationOrchestrator.generateTaskPrompt(userInput)); +} + +public Map continueNegotiation( + NegotiationContext context, NegotiationStatus status, String contentText) { + return observed("continueNegotiation", () -> { + Map result = negotiationOrchestrator.continueNegotiation(context, status, contentText); + reportNegotiationRounds(context, status); + return result; + }); +} + +public Optional getPrompt(@NonNull String templateUri) { + return observed("getPrompt", () -> templateQueryService.findTemplate(TemplateUri.of(templateUri))); +} +``` + +其余 21 个方法按同样模式改写(把现有方法体整体放入 `observed("", () -> )`;`getPrompts` 等无参方法同样处理)。**禁止改动任何业务语义**——只做包装。 + +3) A2ATServer 同构实现:role 用 `"server"`,24→21 个方法按清单包装,`continueNegotiation` 同样接 `reportNegotiationRounds`,`terminalOutcome` 副本同名。 + +- [ ] **Step 4: 运行确认通过 + 全量回归** + +``` +mvn -q -pl a2a-t-client test "-Dtest=A2ATClientObservabilityTest" +mvn -q -pl a2a-t-server test "-Dtest=A2ATServerObservabilityTest" +mvn -q test # 全模块回归(corpus/sample 在内) +``` + +期望:全部 PASS,既有测试零回归。 + +- [ ] **Step 5: env.example 追加可观测性小节** + +``` +# --- Observability (a2a-t-observability) --- +OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED=true +A2AT_OBSERVABILITY_ENABLED=true +A2AT_TRACE_ENABLED=true +A2AT_METRIC_ENABLED=true +A2AT_LOG_ENABLED=true +#OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +A2AT_EXPORTER_PROTOCOL=grpc +A2AT_CONSOLE_EXPORTER=true +#OTEL_SERVICE_NAME=a2a-t-agent +#A2AT_AGENT_ROLE= +A2AT_EXTRACT_REQUEST=false +A2AT_EXTRACT_RESPONSE=false +A2AT_LOGS_PAYLOAD_ENABLED=false +A2AT_LOGS_PAYLOAD_MAX_LENGTH=4096 +``` + +- [ ] **Step 6: 提交** — `mvn -q spotless:apply; git add a2a-t-client a2a-t-server env.example; git commit -m "feat(observability): wire TelemetryHook into client and server facades"` + +--- + +## Plan A 自检清单(完成后执行) + +- [ ] spec §3 表 12 行 → 逐行对应类已存在且被测试覆盖(`attributes.py` 拆分说明见 Task 4;`sdk_trace.py` 的 `SdkOperationSpans` OTel 实现移入 Plan B,因其属于可选实现侧) +- [ ] spec §5 命名注册表 → Task 4 常量 + Task 8 指标名 + Task 9 事件名逐字一致 +- [ ] spec §7 配置表 → Task 3 常量 + Task 5 开关 + Task 9 console/env 处理 +- [ ] spec §8 三条铁律 → Task 9 幂等/降级、Task 10 try/catch 包装、A2ATSpan scope close +- [ ] spec §9 测试策略 → 各 Task 测试 + Task 10 全量回归 +- [ ] `mvn -q spotless:check` 全绿;NullAway 零报错 + +## 后续(Plan B,另行编写) + +a2a-java 协议层装饰器(`A2ATClientTransportDecorator`、`A2ATRequestHandlerDecorator`、`A2ATAgentExecutorDecorator`、`A2ATPushSenderDecorator`、`A2ATA2ATClientTransportWrapper`、类型化提取器 `A2AJavaAttributeExtractors`、`SdkOperationSpans` hook 实现)+ sample 可观测场景 + README。待 Plan A 落地后按其真实签名编写独立计划。 + +### Plan B 必须吸收的终审遗留项(final review 2026-10-20) + +1. `ensureConfigured()` 仅读系统环境变量;`.env` 接线(`fromMap(DotEnvConfigSource.load(path))`)由 Plan B 的 README/sample 负责,并注意 `A2AT_SERVICE_NAME` 未列入 env.example 示例 +2. `PayloadSanitizer` redactor 失败时 fail-open(透传未脱敏值)→ 后续安全加固改 fail-closed(丢弃 payload) +3. `MetricHistograms` 静态缓存跨 `GlobalOpenTelemetry.resetForTest()` 存活 + 装配前缓存的直方图永久绑定 no-op meter → Plan B 设计 provider 感知缓存或在 `ObservabilitySetup.resetForTest` 中清缓存 +4. 终审已修:门面 hook 回调全防护(ae2c526);`getOrNoop()` 防 get() 副作用(ae2c526)——Plan B 的 `SdkOperationSpans` 必须沿用 `getOrNoop()` 模式且自身调用点全部 try/catch 防护;**`TelemetryOperation.close()` 是最后一个未防护 hook 点**(成功路径 try-with-resources 的 close 在 return 后触发,用户实现抛异常会把业务成功变失败)——Plan B 实现的 close() 必须自防护,或后续在门面 observed() 内补 guard +5. 遗留 follow-up(不阻塞合并):console-exporter-disabled 分支跳过 `set()`、ISE 路径 provider 关闭、`replaceAll("/+$")` 多斜杠、`switchOn` 去重、`A2ATCurrentSpan.setAttribute` 类型化 + + + + diff --git a/docs/superpowers/specs/2026-10-20-observability-java-port-design.md b/docs/superpowers/specs/2026-10-20-observability-java-port-design.md new file mode 100644 index 00000000..45ec14da --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-observability-java-port-design.md @@ -0,0 +1,247 @@ +# A2A-T Java SDK 可观测性设计(Python 版移植) + +> **版本**: v1.0 +> **日期**: 2026-10-20 +> **定位**: 将 `a2a-t-sdk-python` 的可观测模块(v3.0 装饰器架构)忠实移植到 `a2a-t-sdk-java` +> **范围决策**: 完全对齐 Python(SDK 内部插桩 + a2a-java 协议层装饰器);OTel 依赖采用 api 编译 + sdk/exporter 可选;双模块拆分;日志 JSON 行格式 + +--- + +## 1. 背景与目标 + +Python 版可观测模块(参照 `a2a-t-sdk-python/observity.md` 与 `src/a2a_t/observability/`)提供三大信号: + +- **Trace**:协议层入口 Span(`SendMessage`/`GetTask` 等)、SSE per-event Span(同 trace 孤儿上下文 + Link)、W3C traceparent 传播 +- **Metrics**:`gen_ai.client.operation.duration`、`gen_ai.client.token.usage`、`a2at.task.request.duration`(双端各记)、`a2at.negotiation.total_rounds` +- **Logs**:结构化事件(`task.request`、`task.status_changed`、`task.artifact`、`negotiation.message` 等) + +### 1.1 Java 版的关键差异 + +| 差异点 | Python | Java | +|---|---|---| +| A2A 协议层归属 | SDK 内含(装饰 a2a-sdk 的 `ClientTransport`/`RequestHandler`) | SDK 模块**不含** a2a-java(仅 sample 依赖);协议层插桩放入独立可选模块 | +| 鸭子类型 | observability 层用 `getattr` 结构式探测,零编译期 a2a 依赖 | Java 无法 duck-type,协议层模块必须编译期依赖 a2a-java 1.1.0.Final | +| 门面插桩 | `trace_facade()` 实例方法动态包装 | 门面为 final 类且 SDK 全同步,改为 `TelemetryHook` SPI + builder `@Nullable` 注入(沿用 `llmClient(@Nullable ...)` 惯例) | +| NoOp 降级 | 手写 `_NoOpTracer` 等类 | OTel API 天然 no-op(未装 SDK 时 `GlobalOpenTelemetry` 返回 no-op),只需开关短路 | +| LLM 用量桥接 | contextvar stash | ThreadLocal(SDK 全同步,无协程切换问题) | +| 客户端流式 | async generator | 回调式 `Consumer` | +| 服务端流式 | EventQueue 装饰 | `Flow.Publisher` 包装 | +| 日志 | Python logging JSON record(LoggingHandler 桥接 OTLP logs) | SLF4J + Jackson 序列化 JSON 行(**决策:JSON 而非仓库 key=value 惯例,对齐后端摄入管道**)+ OTel Logger API 双通道(§3) | + +### 1.2 目标 + +- Span/Span 属性/Metric/Log 事件命名与 Python 版**逐字一致**,保证仪表盘查询零改动 +- 永不破坏业务流:所有插桩点异常吞并 + WARN +- OTel 未安装 / 开关关闭时全链路 pass-through +- 核心 SDK 模块对 OTel 与 a2a-java 保持零依赖 + +## 2. 模块结构(方案 A,已确认) + +``` +a2a-t-observability 核心:配置/装配/手动 API/传播/日志/指标/LLM 装饰器 + 依赖: a2a-t-core, a2a-t-llm, + io.opentelemetry:opentelemetry-api (compile), + opentelemetry-sdk + opentelemetry-exporter-otlp + + opentelemetry-exporter-logging (true) + +a2a-t-observability-a2a 协议层装饰器(可选模块) + 依赖: a2a-t-observability, + org.a2aproject.sdk:a2a-java-sdk-client-transport-spi / -server-common / -spec / -jsonrpc-common + / -transport-rest (1.1.0.Final,RestContextKeys.HEADERS_KEY 提取 headers) +``` + +- 两模块登记进根 `pom.xml` `` 与 `a2a-t-bom` +- 包名:`net.openan.a2at.sdk.observability`(及 `net.openan.a2at.sdk.observability.a2a`),包级 `@NullMarked` +- a2a-java 版本 1.1.0.Final(本地 `.m2` 已有;含 `ClientTransportWrapper` SPI、抽象类 `OpenTelemetryRequestHandlerDecorator` 先例、`PushNotificationSender`、`EventEnqueueHook`) + +### 2.1 既有模块改动(最小侵入) + +| 模块 | 改动 | +|---|---| +| `a2a-t-core` | 新增 `net.openan.a2at.sdk.core.telemetry.TelemetryHook` SPI 接口 + `NO_OP` 常量(见 §4) | +| `a2a-t-client` | `A2ATClient` 新增二级构造器 `A2ATClient(Path envPath, @Nullable TelemetryHook hook)`(既有构造器委托 NO_OP);全部公开方法经 hook 包 `a2at.sdk.client.{method}` Span;`continueNegotiation` 终态时回调 `reportNegotiationRounds` | +| `a2a-t-server` | 同上(`A2ATServer(Path, @Nullable TelemetryHook)`,role=server) | +| `a2a-t-bom` / 根 pom | 登记两新模块与依赖管理 | + +> 注:hook 注入点在**门面二级构造器**而非 builder——builder 只组装 orchestrator(非门面),hook 生命周期属于门面方法包装;与 §4 `trace_facade` 等价机制一致。 + +依赖方向保持单向:`observability → core/llm`,`observability-a2a → observability + a2a-java`。**`ObservabilityLLMClient` 由使用者/sample 组装时显式包装**(builder 不感知 OTel),文档给出示例。 + +## 3. 核心模块 `a2a-t-observability` 内部设计 + +与 Python `src/a2a_t/observability/` 文件一一对应: + +| Python | Java 类 | 要点 | +|---|---|---| +| `config.py` | `A2ATObservabilityConfig` | record + builder + `fromMap(DotEnvConfigSource)`;显式 > 环境变量 > 默认 | +| `setup.py` | `ObservabilitySetup` | `ensureConfigured()`:自动装配 `SdkTracerProvider`/`SdkMeterProvider`/`SdkLoggerProvider` + `BatchSpanProcessor`/`PeriodicExportingMetricReader`/`BatchLogRecordProcessor`(**三信号**,对齐 Python);OTLP gRPC(默认)或 HTTP/protobuf(endpoint 自动拼 `/v1/traces`、`/v1/metrics`、`/v1/logs`;Java `opentelemetry-exporter-otlp` 单 artifact 含三种 exporter);无 endpoint 降级 Console(`opentelemetry-exporter-logging` 的 `LoggingSpanExporter`/`LoggingMetricExporter`/`LoggingLogRecordExporter`);幂等(Java 无 Proxy provider 概念:内部 once 标志 + `GlobalOpenTelemetry.set()` 已设置时抛 `IllegalStateException` 捕获后跳过并 DEBUG);`sdk` 类缺失时 `Class.forName` 探测跳过自动装配 | +| `_otel_compat.py` | `ObservabilityRuntime` | 总开关 `OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED`、tracer/meter 访问器(scope `a2at-observability`/`1.0.0`)、trace/metric/log 三信号独立开关、`sameTraceOrphanContext`(同 trace 随机 span_id 无父上下文 + 0 值守卫) | +| `span.py` | `A2ATSpan implements AutoCloseable` | try-with-resources;kind 枚举 `INTERNAL/CLIENT/SERVER/PRODUCER/CONSUMER`;`setAttribute`/`addLink(A2ATSpan)`。**异常语义**(Java try-with-resources 的 close() 收不到业务异常,与 Python `__exit__(exc)` 不同):增加 `recordError(Throwable)` 方法,惯用法在 catch 中调用;`close()` 按状态收尾——已 `recordError` → record_exception + ERROR,否则 OK。**若使用者既未调用 recordError 又发生异常,Span 以 UNSET 结束**(内部装饰器不经过此路径,状态语义由 §6 的显式 try/finally 全量保证) | +| `current_span.py` | `CurrentSpans.current()` → `A2ATCurrentSpan` | 只读视图:`traceId()`/`spanId()`/`isValid()`/`setAttribute()`;非 recording 时返回空 | +| `attributes.py` | `A2ATAttributes` + `AttributeExtractors` | 全部属性常量 + 提取器(§5 命名注册表) | +| `propagation.py` | `TracePropagation` | `inject(Map)` / `extract(Map)`;W3C traceparent;key 小写容错;extract 返回 null 当 span context 无效 | +| `payload.py` | `PayloadExtractor` + `PayloadSanitizer` | **提取**:a2a-java spec 对象(Jackson 注解 POJO)经 Jackson 序列化为 JSON(对齐 Python `MessageToDict`);**净化**:先脱敏后截断(安全顺序);默认 4096 + `[truncated]` 后缀;自定义 redactor 函数接口 | +| `logs.py` | `ObservabilityLogs` | SLF4J logger `net.openan.a2at.sdk.observability`;**JSON 行**(Jackson 序列化,`ensure_ascii=false` 等价):`{"event": ..., <业务字段>, "trace_id", "span_id", "payload"?}`;事件名沿用 Python;log_enabled 双门控(运行时开关 + config);**OTel 日志信号**:log_enabled 时同步写入 OTel `Logger` API(`SdkLoggerProvider` + OTLP/Console exporter),SLF4J 与 OTel 双通道共享同一 JSON 语义,对齐 Python LoggingHandler 桥接 | +| `llm_decorator.py` + `llm_stash.py` | `ObservabilityLLMClient implements LLMClient` + `LlmUsageStash` | `structured()` 返回后:从 `LLMResponse.usage`(键 `prompt_tokens`/`completion_tokens`,与 Python stash 读法一致)+ `model()` 取数;记 `gen_ai.client.token.usage` 直方图(unit `{token}`,按 `gen_ai.token.type`=input/output 各一样本)+ `LlmUsageStash`(ThreadLocal)暂存(input/output tokens、request model)供协议层入口 Span 取用(**取后清空**;trace 关闭时入口侧直接丢弃 stash) | +| `negotiation_metrics.py` | `NegotiationMetrics` | `a2at.negotiation.total_rounds` 直方图(unit `{round}`);属性 `gen_ai.agent.a2at.extension.name="Negotiation-T"`、`negotiation.id`、`outcome`(accept/reject/abort);仅终态触发;outcome 映射:Java `NegotiationStatus` 仅 `IN_PROGRESS/AGREED/REJECTED`(无 ABORT)→ `AGREED→accept`、`REJECTED→reject`(Python 的 abort 在 Java 无对应,不产生样本);**数据路径差异**:Python 从返回的 `MetadataContent.negotiation_context` 取,Java 从门面入参/结果的 `NegotiationContext(type, negotiationId, round, NegotiationStatus)` 取(终态 status 映射 outcome),结果等价 | +| `sdk_trace.py` | `SdkOperationSpans` | `TelemetryHook` 的 OTel 实现:`a2at.sdk.{role}.{method}` INTERNAL Span | + +指标 instrument 缓存沿用 Python 的惰性单例模式(`ConcurrentHashMap`)。 + +## 4. `TelemetryHook` SPI(a2a-t-core) + +```java +package net.openan.a2at.sdk.core.telemetry; + +public interface TelemetryHook { + TelemetryOperation startSdkOperation(String role, String method); + void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds); + + TelemetryHook NO_OP = new TelemetryHook() { ... }; // 全 no-op +} +``` + +- `TelemetryOperation`:AutoCloseable(try-with-resources 结束 Span)+ `setAttribute(String, Object)`;调用方在业务成功后先调 `markSuccess()` 再 close(close 时若未 markSuccess 且无异常标记则记 ERROR——防御性默认,内部调用点全部受控、两条路径必走其一),异常路径由 try/catch 中的 `markError(Throwable)` 标记。与手动 API `A2ATSpan` 的差异:后者面向使用者,无法感知未上报的异常,未记录时 UNSET 结束(§3),两者语义各自明确 +- 调用点一律 try/catch 包裹 + WARN,业务异常原样抛出 +- `startSdkOperation` 等价 Python `trace_facade`(opt-in L4):注入 hook 后每个门面方法包 Span,名 `a2at.sdk.client.generateTaskPrompt` / `a2at.sdk.server.checkTaskPrompt` 等 + +## 5. 命名注册表(与 Python 逐字一致) + +### 5.1 Span 名(kind) + +| Span 名 | kind | 端 | +|---|---|---| +| `SendMessage` / `SendStreamingMessage` | CLIENT / SERVER | 双端 | +| `SendStreamingMessage-event` | CLIENT / SERVER | 双端(同 trace 孤儿上下文 + Link 指向入口 Span) | +| `SendStreamingMessage-error` | CLIENT | 错误回调(`error.type` 属性;取消类异常豁免) | +| `SendMessage-negotiation` / `SendStreamingMessage-negotiation` | CLIENT / SERVER | 双端,协商 Message 触发(**父子**挂入口 Span;client 由响应/事件观测发,server 流式由 Publisher 侧发、sync 由收尾阶段发,避免双发) | +| `SubscribeToTask` / `GetTask` / `ListTasks` / `CancelTask` | CLIENT / SERVER | 双端 | +| `CreateTaskPushNotificationConfig` / `GetTaskPushNotificationConfig` / `ListTaskPushNotificationConfigs` / `DeleteTaskPushNotificationConfig` | CLIENT / SERVER | 双端 | +| `GetExtendedAgentCard` | CLIENT / SERVER | 双端 | +| `SendMessage-pushNotification` | CLIENT | PushSender 装饰器(Link 入口 Span) | +| `a2at.sdk.{role}.{method}` | INTERNAL | SDK 门面(opt-in) | + +流结束判定:终态事件(`completed`/`failed`/`canceled`/`rejected`)。状态语义:取消 → ERROR "cancelled";其他异常 → record_exception + ERROR;成功 → OK。 + +### 5.2 属性 + +- A2A-T 扩展(前缀 `gen_ai.agent.a2at.`):`extension.name`(由扩展 URI 解析,基址 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/` → `Negotiation-T` 等)、`task.id`、`task.status`、`task.type`、`negotiation.id/round/max_rounds/performative/total_rounds`、`notification.topic`、`streaming.event.kind`、`push.notification.url`、`authorization.policy.operation.type` +- 预授权(无前缀,对齐 Python):`authorization.policy.id`、`authorization.operation_type`、`authorization.operation_risk_level` +- a2a-java 仪表盘双写(前缀 `gen_ai.agent.a2a.`,与 `A2AObservabilityNames` 对齐):`operation.name`、`context_id`、`task_id`、`message_id`、`role`、`extensions`、`parts.number`、`protocol`、`request`、`response` +- GenAI semconv:`gen_ai.operation.name`、`gen_ai.conversation.id`、`gen_ai.usage.input_tokens/output_tokens`、`gen_ai.request.model`、`gen_ai.response.model`、`gen_ai.token.type`、`gen_ai.provider.name`、`streaming`、`error.type` +- 侧别:`a2at.span.side` = `client` | `server` +- 报文正则提取(默认值照搬 Python):任务类型 `##\s*任务类型\(Task Type\)`、通知主题 `##\s*通知主题`、授权操作类型 `##\s*授权策略的操作类型` + +### 5.3 指标 + +| 指标 | 类型/unit | 属性 | +|---|---|---| +| `gen_ai.client.operation.duration` | Histogram, s | `gen_ai.operation.name`(client 端) | +| `gen_ai.client.token.usage` | Histogram, `{token}` | `gen_ai.token.type`=input/output | +| `a2at.task.request.duration` | Histogram, s | `gen_ai.operation.name` + `a2at.span.side`(双端各记) | +| `a2at.negotiation.total_rounds` | Histogram, `{round}` | `extension.name` + `negotiation.id` + `outcome` | + +### 5.4 日志事件 + +`task.request`(DEBUG)、`task.response`(DEBUG)、`task.status_changed`(INFO)、`task.artifact`(INFO)、`notification.push`(INFO)、`negotiation.message`(DEBUG);字段:`task.id`、`task.status`、`artifact.name`、`extension.name`、`streaming.event.kind`(notification.push 携带)、`negotiation.id/round/performative`(negotiation.message 携带)、`trace_id`、`span_id`、`payload?`。 + +## 6. 协议层 `a2a-t-observability-a2a` 设计 + +### 6.1 Client:`A2ATClientTransportDecorator implements ClientTransport` + +- 覆写全部 12 方法;入口 Span kind=CLIENT,名见 §5.1;**构造器防双重装饰守卫**(内层已是本装饰器则解包,杜绝双重 Span,对齐 Python factory-guard) +- **传播**:从 `ClientCallContext.getHeaders()`(小写容错)提取调用方 traceparent 为父(业务根传播 mode B);无则显式 ROOT(`SpanContext` INVALID 语义等价物);向 delegate 传递**新建** `ClientCallContext`(原 state + 注入入口 Span traceparent 的 headers,不改传入对象 —— 相比 Python 原地覆写 `service_parameters` 更安全,消除"覆写调用方业务根"缺陷) +- **流式**(`sendMessageStreaming`/`subscribeToTask`):包装两个 Consumer;每事件发 `SendStreamingMessage-event`(同 trace 孤儿上下文 + Link + `gen_ai.conversation.id`);错误 Consumer 发 `SendStreamingMessage-error`(`error.type`);**取消/中止类异常(`CancellationException`)不发 error span**(对齐 Python CancelledError 豁免)。**注意**:Java 回调式流式模型下装饰器只观测、不可中断内层流——"终态事件"仅用于内部判定流尾(记录 final_event 供响应属性/`task.response` 日志),流的终止由 a2a-java 客户端层控制(与 Python 生成器 `break` 的能力差异,行为等价性由 a2a-java 保证) +- **协商 Span(client 侧)**:sync 响应与流式事件中的协商 Message 发 `{操作名}-negotiation`(CLIENT kind,**父子**挂入口 Span,即时结束;属性 `gen_ai.operation.name`/`extension.name`/`gen_ai.conversation.id`/`task.id`/`negotiation.*`)+ `negotiation.message` 日志 +- **属性**:请求结构化提取 + 双写 + stash 中 LLM token 用量 + 正则提取(DEFAULT_* 回退、空串 opt-out、尾括号清洗);**trace 关闭时 stash 必须丢弃**(防泄漏到下一请求入口 Span,对齐 Python) +- **指标**:`gen_ai.client.operation.duration` + `a2at.task.request.duration`(side=client),双门控(运行时开关 + config 实例开关) +- 另提供 `A2ATA2ATClientTransportWrapper implements ClientTransportWrapper`(a2a-java 原生 SPI,可注册自动包装,等价 Python `register_client_factory`) + +### 6.2 Server:`A2ATRequestHandlerDecorator implements RequestHandler` + +- `onMessageSend`→`SendMessage`;`onMessageSendStream`→`SendStreamingMessage`(Span 存活至流结束,try/finally);`onSubscribeToTask`→`SubscribeToTask`(仅入口,不重复观测——事件由产生请求侧观测);其余简单包装;kind=SERVER +- **父上下文**:`ServerCallContext.getState()` 按 `RestContextKeys.HEADERS_KEY`(transport-rest)提取 headers,找不到时通用探测 `headers` key(小写容错,兼容 jsonrpc transport) +- **流式 per-event**:包装返回的 `Flow.Publisher`(订阅者包装,**仅流式操作**——同步 `SendMessage` 不产生幻影 event Span,对齐 Python `_start_event_span` 门);每事件 `SendStreamingMessage-event` SERVER Span(孤儿 + Link);协商 Message 事件发 `SendStreamingMessage-negotiation`(**父子**)+ `negotiation.message` 日志 +- **同步 `SendMessage` 的协商 Span**:响应分类后在收尾阶段发(对齐 Python `_capture_response`,EventQueue/Publisher 侧只管流式,避免双发) +- **入口 Span 注册表**:有界 map(上限 2048,FIFO 淘汰),task_id 与 context_id 双 key 注册;**context_id fallback 仅当该记录的入口 Span 仍在 recording 时可被采纳**(防止复用上一请求的记录,对齐 Python `_find_record`) +- **指标**:`a2at.task.request.duration`(side=server,双门控) + +### 6.3 协作装饰器 + +- `A2ATAgentExecutorDecorator implements AgentExecutor`:激活入口 Span(业务代码可见 `current_span()`)。用法:**先包 executor 再 `DefaultRequestHandler.create(...)`,后包 handler**(create 显式收 executor,无需反射) +- `A2ATPushSenderDecorator implements PushNotificationSender`:`SendMessage-pushNotification` CLIENT Span + Link 入口 Span + `push.notification.url` 属性。**注入点**:a2a-java 的 push 发送经 `MainEventBusProcessor(MainEventBus, TaskStore, PushNotificationSender, QueueManager)` 公共构造器注入 → 先装饰 sender 再构造。**URL 提取**:a2a-java `sendNotification(event, task)` 不携带 URL(URL 在内部私有 dispatch 中);装饰器持有 `PushNotificationConfigStore`,发 Span 时按 task_id 查询配置取 URL(对齐 Python 经 `push_info.url` 的语义) +- `PayloadSanitizer` 复用核心模块;payload 提取(request/response JSON)受 `A2AT_EXTRACT_REQUEST/RESPONSE` 门控;span 属性通道(`gen_ai.agent.a2a.request/response`)与日志通道(`task.request`/`task.response`)共享同一 redact→truncate 语义 + +### 6.4 与 a2a-java 自带 OTel 插桩的关系 + +**二选一,不叠加**:使用本模块装饰器时,不得同时注册 `OpenTelemetryClientTransportWrapper` / `OpenTelemetryClientPropagatorTransportWrapper` / `OpenTelemetryRequestHandlerDecorator`,否则双重 Span。README 明示。 + +## 7. 配置 + +| 键 | 默认 | 说明 | +|---|---|---| +| `OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED` | `true` | 总开关,false → 全 no-op | +| `A2AT_OBSERVABILITY_ENABLED` | `true` | 装饰器门闸,false → 装饰器纯转发 | +| `A2AT_TRACE_ENABLED` / `A2AT_METRIC_ENABLED` / `A2AT_LOG_ENABLED` | `true` | 三信号独立(trace 关,metrics/logs 照常) | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | 无 | 无 → Console 降级 | +| `A2AT_EXPORTER_PROTOCOL` | `grpc` | `grpc` \| `http/protobuf`(别名 `http`;非法 → grpc + WARN) | +| `A2AT_CONSOLE_EXPORTER` | `true` | 关闭 Console 降级 | +| `OTEL_SERVICE_NAME` / `A2AT_SERVICE_NAME` | `a2a-t-agent` | Resource `service.name`;`A2AT_AGENT_ROLE` → Resource `agent.role`(opt-in) | +| `A2AT_EXTRACT_REQUEST` / `A2AT_EXTRACT_RESPONSE` | `false` | payload 上 Span 属性 | +| `A2AT_LOGS_PAYLOAD_ENABLED` | `false` | 日志 payload 通道 | +| `A2AT_LOGS_PAYLOAD_MAX_LENGTH` | `4096` | 截断上限(`[truncated]`) | + +读取顺序:`.env`(复用 core `DotEnvConfigSource`)→ 系统环境变量 → 默认值。`A2ATObservabilityConfig` 可编程构造传入各装饰器;正则可覆盖(空串 = opt-out)。懒装配:首次装饰调用时 `ensureConfigured()`。 + +## 8. 降级与错误处理(三条铁律) + +1. **永不破坏业务流**:插桩点 try/catch + WARN(logger `net.openan.a2at.sdk.observability`),业务异常原样抛出 +2. **全链路 pass-through**:OTel SDK 未安装 / 开关关 → 装饰器纯转发;OTel API 层天然 no-op 兜底 +3. **Span 激活防泄漏(Java 简化)**:SDK 全同步,`Span.makeCurrent()` 返回的 `Scope` 在同线程 close 即可靠恢复——不需要 Python 的快照+强制修复逻辑(§11#10);跨线程路径(executor 跑在 worker 线程、push 跑在 MainEventBusProcessor 线程)均在线程入口处显式激活/关闭,不依赖 ambient 跨线程继承 + +## 9. 测试策略 + +沿用仓库惯例:JUnit 5 + 手写 scripted stub + logback `ListAppender`;无 Mockito。 + +- **核心模块**:配置解析矩阵(显式/env/默认/非法值)、装配幂等与降级(无 endpoint → Console;无 sdk 类 → 跳过)、传播 inject/extract 往返与容错、Payload 脱敏→截断顺序、LLM 装饰器 token 指标与 stash 取后清空、协商指标终态触发 +- **协议层**:scripted `ClientTransport`/`RequestHandler`/`AgentExecutor`/`PushNotificationSender` fake + a2a-java spec 真实类型构造请求/事件;用 `SdkTracerProvider` + 内存 `SpanProcessor`(收集已结束 Span)断言:Span 名/kind/属性/状态/Link 拓扑/per-event 序列/双端指标;**双重装饰守卫**(装饰器套装饰器只出一层 Span);**取消豁免**(CancellationException 不发 error span);**context_id fallback 仅 recording 可采纳** +- **公共 API 导出面合同**(对齐 Python `test_public_api.py` 的 "nothing more, nothing less"),Java 导出面基准: + - 装配:`ObservabilitySetup.setup / isConfigured` + - 集成:`A2ATClientTransportWrapper`、`A2ATRequestHandlerDecorator`(+ Executor/PushSender 装饰器) + - 手动 API:`A2ATSpan`(try-with-resources)、`CurrentSpans.current()` / `A2ATCurrentSpan` + - 门面追踪:`TelemetryHook`(+ `SdkOperationSpans` 实现) + - 传播:`TracePropagation.inject/extract`、`TRACEPARENT_HEADER` + - 配置:`A2ATObservabilityConfig` + - 属性常量:§5.2 全量(A2A-T 扩展 14 个 + 预授权 3 个 + GenAI 7 个 + `a2at.span.side`/`streaming`/`error.type`) +- **日志 JSON 字段合同**(ListAppender + Jackson 解析断言 event/trace_id/span_id/payload) +- **既有模块回归**:builder `@Nullable` 注入点新增注入测试;全量 `mvn test` +- **Sample**:`a2a-t-sample` 新增 `observability` 示例场景(对标 Python `observability-sample`:Console 模式端到端跑通 trace/metrics/logs) + +## 10. 交付边界(不做的事) + +- 不做 Micrometer/Prometheus 桥、StatsD、Zipkin +- 不做 ByteBuddy/Java Agent 字节码插桩 +- 不做动态实例包装(Python `trace_facade` 的 Java 等价由 builder 注入 hook 实现) +- 不叠加 a2a-java 自带 OTel 插桩(二选一) +- 协议层模块不追求兼容 a2a-java 1.0.0.Beta1(锁定 1.1.0.Final) + +## 11. 可行性核查结论(2026-10-20,源码级验证) + +| # | 接缝 | 结论 | +|---|---|---| +| 1 | Client 12 方法装饰 + `ClientCallContext(state, headers)` 新建实例注入 traceparent | ✅ 可行 | +| 2 | Server 11 方法装饰 + `Flow.Publisher` 订阅者包装 per-event Span | ✅ 可行 | +| 3 | traceparent 提取:`RestContextKeys.HEADERS_KEY`(transport-rest 公共常量)+ 通用探测回退 | ✅ 可行(依赖清单已补 transport-rest) | +| 4 | PushNotificationSender 注入:`MainEventBusProcessor(MainEventBus, TaskStore, PushNotificationSender, QueueManager)` 公共构造器 | ✅ 可行(DefaultRequestHandler 不直接收 sender;先装饰再构造) | +| 5 | `push.notification.url`:`sendNotification(event, task)` 无 URL 参数 | ⚠️ 调整:装饰器持 `PushNotificationConfigStore` 按 task_id 查 URL(§6.3) | +| 6 | `A2ATAgentExecutorDecorator`:`DefaultRequestHandler.create` 显式收 executor | ✅ 先包后建,无需反射 | +| 7 | LLM token:`LLMResponse.usage` 键 `prompt_tokens`/`completion_tokens` + `model()` | ✅ 与 Python stash 读法一致 | +| 8 | 协商轮次指标:Java 从入参 `NegotiationContext` 取(Python 从返回值取) | ✅ 数据路径小异,结果等价 | +| 9 | 同 trace 孤儿上下文:`SpanContext.create(traceId, randomSpanId, flags, state)` + `spanBuilder.setParent` | ✅ 可行(含 0 值 span_id 守卫) | +| 10 | 防泄漏激活:Java SDK 全同步,`Span.makeCurrent()`/`Scope.close()` 同步可靠 | ✅ 简化(Python 的跨 async 上下文修复逻辑不需要) | + +## 12. 参考 + +- Python 实现:`a2a-t-sdk-python/src/a2a_t/observability/`(对照文件清单见 §3 表格;`__init__.py` 公共导出 24 符号为 API 面合同基准) +- Python 设计大纲:`a2a-t-sdk-python/observity.md` +- a2a-java 接缝(javap + 常量池扫描验证 1.1.0.Final):`ClientTransport`、`ClientTransportWrapper`、`ClientCallContext(Map state, Map headers)`、`RequestHandler`(流式返回 `Flow.Publisher`)、`ServerCallContext.getState()` + `RestContextKeys.HEADERS_KEY`、`AgentExecutor`、`PushNotificationSender`/`BasePushNotificationSender`(私有 `dispatch(event, task, TaskPushNotificationConfig, headers)` 含 URL)、`MainEventBusProcessor` 构造器、`DefaultRequestHandler.create(AgentExecutor, TaskStore, QueueManager, PushNotificationConfigStore, MainEventBusProcessor, Executor, Executor)`、`A2AObservabilityNames` 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..6712a7bd --- /dev/null +++ "b/docs/zh/API\345\217\202\350\200\203.md" @@ -0,0 +1,1674 @@ +# 1 API参考 + +## 1.1 简介 + +A2A-T SDK 对外仅提供两个入口:客户端入口 `A2ATClient`(提示词生成、协商消息生成与校验)与服务端入口 `A2ATServer`(提示词校验、协商消息生成与校验),API 分类总览见下。 + +- **API 总览** + +| 分类 | API定义 | Client/Server | API含义 | 是否涉及LLM | +| ---- | ------- | -------- | ------- |---------| +| Negotiation-T | `generateNegotiationProposePromptFromText` | A2A-T Client / A2A-T Server | 从自然语言文本生成协商发起(propose)报文 | 是 | +| Negotiation-T | `generateNegotiationAcceptPromptFromText` | A2A-T Client / A2A-T Server | 从自然语言文本生成协商接受(accept)报文 | 是 | +| Negotiation-T | `generateNegotiationRejectPromptFromText` | A2A-T Client / A2A-T Server | 从自然语言文本生成协商拒绝(reject)报文 | 是 | +| Negotiation-T | `generateNegotiationProposePromptFromData` | A2A-T Client / A2A-T Server | 从结构化数据确定性生成协商发起报文 | 否 | +| Negotiation-T | `generateNegotiationAcceptPromptFromData` | A2A-T Client / A2A-T Server | 从结构化数据确定性生成协商接受报文 | 否 | +| Negotiation-T | `generateNegotiationRejectPromptFromData` | A2A-T Client / A2A-T Server | 从结构化数据确定性生成协商拒绝报文 | 否 | +| Negotiation-T | `validateProposePromptAndDataFilling` | A2A-T Client / A2A-T Server | 校验协商发起报文合规性并按Schema提取参数 | 是 | +| Negotiation-T | `validateAcceptPromptAndDataFilling` | A2A-T Client / A2A-T Server | 校验协商接受报文合规性并按Schema提取参数 | 是 | +| Negotiation-T | `validateRejectPromptAndDataFilling` | A2A-T Client / A2A-T Server | 校验协商拒绝报文合规性并按Schema提取参数 | 是 | +| Task-T | `generateTaskPromptFromText` | A2A-T Client | 从自然语言文本按指定Task-T模板生成任务提示词(跳过场景识别) | 是 | +| Task-T | `generateTaskPromptFromDataWithSchema` | A2A-T Client | 从结构化数据+语义Schema按指定Task-T模板生成任务提示词 | 是 | +| Task-T | `validateTaskPromptAndDataFilling` | A2A-T Server | 校验Task-T任务提示词合规性并按Schema提取参数 | 是 | +| Notification-T | `generateNotificationPromptFromText` | A2A-T Client | 从自然语言文本按指定Notification-T模板生成通知订阅提示词 | 是 | +| Notification-T | `generateNotificationPromptFromDataWithSchema` | A2A-T Client | 从结构化数据+语义Schema按指定Notification-T模板生成通知订阅提示词 | 是 | +| Notification-T | `validateNotificationPromptAndDataFilling` | A2A-T Server | 校验Notification-T提示词合规性并按Schema提取参数 | 是 | +| Authorization-T | `generateAuthPromptFromText` | A2A-T Client | 从自然语言文本按指定Authorization-T模板生成授权提示词 | 是 | +| Authorization-T | `generateAuthPromptFromDataWithSchema` | A2A-T Client | 从结构化数据+语义Schema按指定Authorization-T模板生成授权提示词 | 是 | +| Authorization-T | `validateAuthPromptAndDataFilling` | A2A-T Server | 校验Authorization-T提示词合规性并按Schema提取参数 | 是 | +| 通用接口 | `generateTaskPrompt` | A2A-T Client | 从自然语言或结构化输入经场景识别生成任务提示词 | 是 | +| 通用接口 | `checkTaskPrompt` | A2A-T Server | 校验任务提示词的场景、模板与槽位合规性 | 是 | + +**公共数据类型与约定** + +- **协商会话上下文:**`NegotiationContext(id, round, maxRounds)`(id 为 UUID 形态、round 从 1 起、默认轮次 `DEFAULT_MAX_ROUNDS = 5`),随消息 metadata 传输、不经 LLM;`NegotiationContext.of(id, round)` 使用默认轮次,`nextRound()` 推进轮次。 +- **异常体系:** 所有 SDK 处理失败均为 `A2ATError` 子类,捕获 `A2ATError` 即可覆盖全部处理失败,`getCode()` 获取机器可读错误码。所有消息均由 SDK 按错误码模板渲染,语言跟随 `A2AT_LANGUAGE`。如下业务异常均继承 `A2ATBusinessException`,额外提供 `getFacts()` 返回渲染消息所依据的结构化事实值; + - 模板生成失败抛 `PromptGenerationException`(Task-T / Notification-T / Authorization-T)或 `NegotiationGenerationException`(Negotiation-T)。 + - 模板校验+提参失败抛 `ContentValidationException`(Task-T / Notification-T / Authorization-T)或 `NegotiationParamExtractionException`(Negotiation-T)。 +- **SlotValidationError:** 逐槽位校验错误明细,随校验失败异常或失败负载返回(各接口输出说明中的 `getErrors()` / `failedParameters()` / `errors()` 均引用此定义): + + | 字段 | 类型 | 说明 | + | ---- | ---- |--------------------------------------------------------------------------------------------------------------------------| + | slotName | String | 出错槽位名 | + | code | String | 槽位级错误码,取值来自 1.4 错误码列表,如 `slot.not_provided`、`content.param_missing`、`content.entry_field_missing`、`content.format_error` | + | message | String | 人类可读的错误说明,由 SDK 按错误码的消息模板渲染,语言跟随 `A2AT_LANGUAGE` | + | facts | Map<String, String> | 渲染消息所依据的结构化事实值(如 `section_label`、`index`、`field_label`),可为 null | + +- **模板 URI:** `A2ATClient` 与 `A2ATServer` 门面上所有指定模板的方法均以 `String templateUri`(如 `Task-T/network-layer/private-line-complaint/v1`)声明模板选择,推荐直接传 `net.openan.a2at.sdk.core.model.StandardTemplates` 的 String 常量(常量名以 `_URI` 结尾,每个都有去掉后缀的同名 `TemplateUri` 类型化孪生常量);来自外部的字符串直接传入即可。类型 `TemplateUri` 仍用于底层服务层(如 `NegotiationContentService`),可用 `TemplateUri.parse(String)` 解析,返回 `Optional` 且不抛异常。当前支持的模板 URI 常量(String 形态)如下: + + | 常量名称 | 含义 | TemplateUri | + |-------------------------------------------------------------| ---- | ---- | + | StandardTemplates.ENERGY_SAVING_URI | Task-T 节能任务模板 | Task-T/network-layer/ran-energy-saving/v1 | + | StandardTemplates.PRIVATE_LINE_COMPLAINT_URI | Task-T 专线投诉任务模板 | Task-T/network-layer/private-line-complaint/v1 | + | StandardTemplates.SUBSCRIBE_INCIDENT_URI | Notification-T 事件订阅通知模板 | Notification-T/network-layer/subscribe-incident/v1 | + | StandardTemplates.SERVICE_RECOVERY_URI | Notification-T 业务抢通通知模板 | Notification-T/network-layer/service-recovery/v1 | + | StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI | Authorization-T 授权策略管理模板 | Authorization-T/authorization-policy-management/v1 | + | StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI | Negotiation-T 信息协商意图发起模板 | Negotiation-T/information-negotiation/propose/v1 | + | StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T 信息协商接受/拒绝模板 | Negotiation-T/information-negotiation/accept-reject/v1 | + | StandardTemplates.TARGET_NEGOTIATION_PROPOSE_URI | Negotiation-T 目标协商意图发起模板 | Negotiation-T/target-negotiation/propose/v1 | + | StandardTemplates.TARGET_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T 目标协商接受/拒绝模板 | Negotiation-T/target-negotiation/accept-reject/v1 | + | StandardTemplates.FEASIBILITY_NEGOTIATION_PROPOSE_URI | Negotiation-T 可行性协商意图发起模板 | Negotiation-T/feasibility-negotiation/propose/v1 | + | StandardTemplates.FEASIBILITY_NEGOTIATION_ACCEPT_REJECT_URI | Negotiation-T 可行性协商接受/拒绝模板 | Negotiation-T/feasibility-negotiation/accept-reject/v1 | + | StandardTemplates.NEGOTIATION_ABORT_URI | Negotiation-T 协商中止通用模板 | Negotiation-T/common/abort/v1 | + +- **模板 URI 校验策略:** 所有带 `templateUri` 参数的方法均接收原始 URI 字符串。templateUri 为 null 抛 `NullPointerException`;为空白或非法 URI(少于三段,或某一段不是简单段)抛 `IllegalArgumentException`。 + + +## 1.2 约束和限制 + +- 部分接口涉及到LLM调用,使用时需根据对所接模型服务可提供的并发能力,结合业务时限要求,控制使用API时的调用频率和并发。 +- 涉及到LLM调用的API针对文本`text`输入会做长度防护,在输入超过`client.env`或`server.env`中配置的 `A2AT_INPUT_TEXT_MAX_CHARS` 字符时,会报错误码 `input.text_too_long` ,该配置项默认配置为 16384(16×1024);不涉及LLM调用的结构化数据不受此限制。 + +## 1.3 API说明 + +### 1.3.1 generateNegotiationProposePromptFromText + +**API定义** + +```java +public MetadataContent generateNegotiationProposePromptFromText( + String text, NegotiationContext context, String templateUri) +``` + +**典型场景**:服务端Agent收到参数不全的Task-T任务报文后,用自然语言向客户端Agent发起"补充缺失信息"的协商请求;也适用于客户端Agent向服务端发起目标澄清或可行性评估请求。 + +**功能说明**:从自然语言文本生成协商发起(propose)阶段的结构化协商报文。执行流程:先加载模板,再执行一次 LLM 内容抽取,最后确定性渲染模板。适用于信息/目标/可行性协商的发起方。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| text | String | 是 | 描述协商发起内容的自然语言文本(如请求补充的缺失信息清单);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | +| context | NegotiationContext | 是 | 协商会话上下文,不经 LLM 直接注入生成消息的 `negotiationContext` metadata | +| templateUri | String | 是 | propose 模板,如 `StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI` | + +**请求样例** + +```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( + "请提供以下缺失信息:投诉分类:专线中断或专线质差。两个参数均为必选,缺少无法启动诊断。", + ctx, + StandardTemplates.INFORMATION_NEGOTIATION_PROPOSE_URI); + +// 生成的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`(无法从文本抽取结构化内容,可重试) + +- `llm.invocation_failed`(LLM 传输失败,可重试) + +- `llm.response_invalid`(LLM 返回内容不符合步骤要求,可重试) + +- `negotiation.invalid_input`(文本为空、抽取内容与阶段不符、确认请求与其它板块组合矛盾) + +- `negotiation.field_missing`(缺少必填字段) + +context 或 templateUri 为 null 抛 `NullPointerException`,templateUri 为空白或非法、或 phase 段不是 `propose` 抛 `IllegalArgumentException`;text 为 null 或空白不属编程错误,以 `negotiation.invalid_input` 抛 `NegotiationGenerationException`(见上错误码)。 + +**响应样例** + +```text +templateUri : Negotiation-T/information-negotiation/propose/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T/v1 +promptText : +## 信息协商 +请根据<所需信息项>补充相关内容。 + +## 所需信息项 +1. 投诉分类:举例:专线质差 +``` + +### 1.3.2 generateNegotiationAcceptPromptFromText + +**API定义** + +```java +public MetadataContent generateNegotiationAcceptPromptFromText( + String text, NegotiationContext context, String templateUri) +``` + +**典型场景**:协商响应方(通常是客户端Agent)收到对端的信息协商请求后,用自然语言补充/交付所请求的信息并生成接受报文回传,如补齐接入端口名称与投诉分类后确认启动诊断。 + +**功能说明**:从自然语言文本生成协商接受(accept)报文。一次 LLM 内容抽取(抽取出的结论必须为 `ACCEPT`,否则以 `negotiation.conclusion_mismatch` 拒绝)+ 确定性渲染。适用于协商响应方补充/交付信息。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| text | String | 是 | 描述接受内容的自然语言文本(如补充交付的信息清单);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | +| context | NegotiationContext | 是 | 协商会话上下文 | +| templateUri | String | 是 | accept-reject 模板,如 `StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI` | + +**请求样例** + +```java +MetadataContent accept = client.generateNegotiationAcceptPromptFromText( + "同意补充以下信息:1. 接入端口名称:P533-珠江旧城-PTN3900-23-TPA1EG24-1;" + + "2. 投诉分类:专线质差。信息已完整,可以启动诊断。", + ctx, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.1](#131-generatenegotiationproposepromptfromtext))。 + +失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: + +- `template.not_found`(模板或提示词资源缺失) + +- `negotiation.content_extract_failed`(无法从文本抽取结构化内容,可重试) + +- `llm.invocation_failed`(LLM 传输失败,可重试) + +- `llm.response_invalid`(LLM 返回内容不符合步骤要求,可重试) + +- `negotiation.invalid_input`(文本为空) + +- `negotiation.conclusion_mismatch`(抽取结论不是 `ACCEPT`) + +- `negotiation.field_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, String templateUri) +``` + +**典型场景**:协商响应方(通常是客户端Agent)无法满足对端的协商请求时,用自然语言生成拒绝报文回传并结束本轮协商,如因站点清单不可用无法提供接入端口名称。 + +**功能说明**:从自然语言文本生成协商拒绝(reject)报文。一次 LLM 内容抽取(抽取出的结论必须为 `REJECT`,否则以 `negotiation.conclusion_mismatch` 拒绝)+ 确定性渲染。 + +**输入说明**:同 [generateNegotiationAcceptPromptFromText](#132-generatenegotiationacceptpromptfromtext),text 为描述拒绝原因的自然语言文本。 + +**请求样例** + +```java +MetadataContent reject = client.generateNegotiationRejectPromptFromText( + "拒绝补充信息:接入端口名称因站点清单不可用而无法提供,本次协商结束。", + ctx, + StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.1](#131-generatenegotiationproposepromptfromtext))。 + +失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: + +- `template.not_found`(模板或提示词资源缺失) + +- `negotiation.content_extract_failed`(无法从文本抽取结构化内容,可重试) + +- `llm.invocation_failed`(LLM 传输失败,可重试) + +- `llm.response_invalid`(LLM 返回内容不符合步骤要求,可重试) + +- `negotiation.invalid_input`(文本为空) + +- `negotiation.conclusion_mismatch`(抽取结论不是 `REJECT`) + +- `negotiation.field_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, String templateUri) +``` + +**典型场景**:与 fromText 相同的发起场景,但输入为业务系统构造的结构化数据(如服务端Agent根据 `validateTaskPromptAndDataFilling` 检出的缺失槽位清单自动生成协商请求条目),适合对报文内容有确定性要求、不希望引入 LLM 抽取不确定性的场景。 + +**功能说明**:从结构化数据输入确定性生成协商发起报文,**不调用 LLM**。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| data | `NegotiationProposeData(context, content)` | 是 | 协商上下文 + 类型化发起内容;内容类型按协商类型选择 | +| templateUri | String | 是 | propose 模板 | + +三种协商类型的发起内容: + +| 协商类型 | Propose 内容类型 | 字段 | +| -------- | ---------------- | ---- | +| 信息协商 | `InformationProposeContent` | `items`(缺失项清单)、`relationship`(缺失项间关系,可空) | +| 目标协商 | `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` 皆为空;内容按评估类别固定为"评估目标可行,是否同意按照此目标继续执行?"(目标达成)或"评估方案可行,是否同意按照此方案继续执行?"(方案可行性)) | + +**请求样例** + +```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_URI); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.1](#131-generatenegotiationproposepromptfromtext),协商报文均携带 `negotiationContext`)。 + +失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: + +- `template.not_found`(模板缺失) + +- `negotiation.content_invalid`(类型化内容字段无效,如必填 items 为空、必填描述为空白) + +- `template.render_failed`(模板渲染失败) + +另有两类编程错误(`A2ATError` 树外,标准 JDK 异常):入参或其 context 为 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.5 generateNegotiationAcceptPromptFromData + +**API定义** + +```java +public MetadataContent generateNegotiationAcceptPromptFromData( + NegotiationEndingData data, String templateUri) +``` + +**典型场景**:协商响应方(通常是客户端Agent)按对端请求的槽位清单程序化补参后,以结构化条目生成接受报文回传。 + +**功能说明**:从结构化数据输入确定性生成协商接受报文,**不调用 LLM**。`content.conclusion()` 必须为 `ACCEPT`,其他结论(含 `ABORT`)以 `negotiation.conclusion_mismatch` 业务错误拒绝。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| data | `NegotiationEndingData(context, content)` | 是 | 协商上下文 + 类型化接受内容(结论为 `ACCEPT`) | +| templateUri | String | 是 | 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_URI); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.1](#131-generatenegotiationproposepromptfromtext))。 + +失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: + +- `template.not_found`(模板缺失) + +- `negotiation.content_invalid`(类型化内容字段无效,如必填 items 为空、必填描述为空白) + +- `template.render_failed`(模板渲染失败) + +编程错误:入参或其 context 为 null 抛 `NullPointerException`;templateUri 为空白或非法、内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;`conclusion` 不是 `ACCEPT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 + +**响应样例** + +```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, String templateUri) +``` + +**典型场景**:协商响应方程序化判定无法满足对端请求后,以结构化条目(无法提供的项及原因)生成拒绝报文回传。 + +**功能说明**:从结构化数据输入确定性生成协商拒绝报文,**不调用 LLM**。`content.conclusion()` 必须为 `REJECT`,其他结论以 `negotiation.conclusion_mismatch` 业务错误拒绝。 + +**输入说明**:同 [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_URI); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.1](#131-generatenegotiationproposepromptfromtext))。 + +失败时抛 `NegotiationGenerationException`(结构同 1.3.1)。错误码: + +- `template.not_found`(模板缺失) + +- `negotiation.content_invalid`(类型化内容字段无效,如必填 items 为空、必填描述为空白) + +- `template.render_failed`(模板渲染失败) + +编程错误:入参或其 context 为 null 抛 `NullPointerException`;templateUri 为空白或非法、内容类型不符或 phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;`conclusion` 不是 `REJECT` 以 `negotiation.conclusion_mismatch` 业务错误拒绝。 + +**响应样例** + +```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, String templateUri) +``` + +**典型场景**:发起方(服务端Agent)发送协商请求前的出站自检;或接收方(客户端Agent)校验收到的协商请求并提取需补充的槽位清单,驱动后续补参。 + +**功能说明**:校验一条协商发起(propose)报文是否为格式正确的协商消息,并按调用方提供的 JSON Schema 从中提取参数。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| prompt | String | 是 | 待校验的协商发起报文文本(`MetadataContent.promptText()`);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | +| context | NegotiationContext | 是 | 随报文传输的协商上下文;为 null 时报 `negotiation.invalid_input` | +| schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema,声明要提取的参数 | +| templateUri | String | 是 | 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_URI); + +// 过滤掉上下文参数(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`(prompt 为 null 或空白、报文不是协商消息,或 context 为 null) + +- `negotiation.rule_violation`(协商上下文违反规则;`getErrors()` 中的嵌套槽位错误码标识具体规则,如 `negotiation.invalid_context_id`、`negotiation.round_exceeded`) + +- `negotiation.semantic_rejected`(语义校验拒绝;`getErrors()` 中的逐槽位明细使用封闭的 `negotiation.*` 码集,如 `negotiation.conclusion_content_mismatch`、`negotiation.missing_result_content`、`negotiation.field_inconsistency`) + +- `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) + +- `template.not_found`(校验提示词资源缺失) + +编程错误:schema / templateUri 为 null 抛 `NullPointerException`,templateUri 为空白或非法、phase 段不匹配抛 `IllegalArgumentException`;prompt 为 null 或空白不属编程错误,以 `negotiation.invalid_input` 抛 `NegotiationParamExtractionException`(见上错误码)。 + +**响应样例** + +```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, String templateUri) +``` + +**典型场景**:发起方(服务端Agent)校验对端回传的接受报文,提取交付的参数值并与期望补齐值核对,确认无误后继续任务执行。 + +**功能说明**:校验一条协商接受(accept)报文并按 Schema 提取参数。 + +**输入说明**:同 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_URI); +``` + +**输出说明** + +成功时返回 `FilledParamData`(结构同 [1.3.7](#137-validateproposepromptanddatafilling)),`data()` 携带交付内容中按 Schema 提取的参数与上下文参数。 + +失败时抛 `NegotiationParamExtractionException`(结构同 1.3.7)。错误码: + +- `negotiation.invalid_input`(prompt 为 null 或空白、报文不是 accept 协商消息,或 context 为 null) + +- `negotiation.rule_violation`(协商上下文违反规则;`getErrors()` 中的嵌套槽位错误码标识具体规则,如 `negotiation.invalid_context_id`、`negotiation.round_exceeded`) + +- `negotiation.semantic_rejected`(结论不是 Accept 或内容不满足 accept 阶段约束) + +- `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) + +- `template.not_found`(校验提示词资源缺失) + +编程错误:schema / templateUri 为 null 抛 `NullPointerException`,templateUri 为空白或非法、phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;prompt 为 null 或空白不属编程错误,以 `negotiation.invalid_input` 抛 `NegotiationParamExtractionException`(见上错误码)。 + +**响应样例** + +```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, String templateUri) +``` + +**典型场景**:发起方(服务端Agent)校验对端回传的拒绝报文,提取拒绝原因,据此终止任务或转入人工处理。 + +**功能说明**:校验一条协商拒绝(reject)报文并按 Schema 提取参数。 + +**输入说明**:同 1.3.7,prompt 为 reject 报文文本,templateUri 为 accept-reject 模板。 + +**请求样例** + +```java +FilledParamData rejectParams = server.validateRejectPromptAndDataFilling( + rejectPrompt, ctx, schema, StandardTemplates.INFORMATION_NEGOTIATION_ACCEPT_REJECT_URI); +``` + +**输出说明** + +成功时返回 `FilledParamData`(结构同 [1.3.7](#137-validateproposepromptanddatafilling)),`data()` 携带拒绝原因中按 Schema 提取的参数与上下文参数。 + +失败时抛 `NegotiationParamExtractionException`(结构同 1.3.7)。错误码: + +- `negotiation.invalid_input`(prompt 为 null 或空白、报文不是 reject 协商消息,或 context 为 null) + +- `negotiation.rule_violation`(协商上下文违反规则;`getErrors()` 中的嵌套槽位错误码标识具体规则,如 `negotiation.invalid_context_id`、`negotiation.round_exceeded`) + +- `negotiation.semantic_rejected`(结论不是 Reject 或内容不满足 reject 阶段约束) + +- `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) + +- `template.not_found`(校验提示词资源缺失) + +编程错误:schema / templateUri 为 null 抛 `NullPointerException`,templateUri 为空白或非法、phase 段不是 `accept-reject` 抛 `IllegalArgumentException`;prompt 为 null 或空白不属编程错误,以 `negotiation.invalid_input` 抛 `NegotiationParamExtractionException`(见上错误码)。 + +**响应样例** + +```text +rejectParams.data() = +{ + 接入端口名称=无法提供,工作台侧端口资源台账暂不可查, + id=3dbc13b5-bd57-4c2b-b503-24e381b6c8d3, + round=1, + maxRounds=5 +} +``` + +### 1.3.10 generateTaskPromptFromText + +**API定义** + +```java +public MetadataContent generateTaskPromptFromText(String text, String templateUri) +``` + +**典型场景**:客户端Agent将用户的自然语言任务描述(如专线投诉诊断诉求)转换为指定场景的Task-T协议报文,适合已确定目标模板、需跳过场景识别的场景。 + +**功能说明**:从自然语言文本按指定 Task-T 模板生成任务提示词报文,**跳过场景识别**(模板由调用方显式指定)。执行一次 LLM 槽位提取后确定性渲染模板。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| text | String | 是 | 自然语言任务描述;输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | +| templateUri | String | 是 | Task-T 模板,如 `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI`(`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_URI); +``` + +**输出说明** + +成功时返回 `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.not_provided` 等槽位域失败码时非空),结构见公共约定 | + +错误码: + +- `template.not_found`(模板缺失) + +- `template.load_failed`(提示词资源加载失败) + +- `slot.schema_not_found`(槽位 Schema 缺失) + +- `llm.invocation_failed` / `llm.response_invalid`(LLM 调用失败,可重试) + +- `slot.not_provided`(必填槽在输入中未提供) + +- `slot.constraint_violated`(槽位取值不在允许范围内) + +- `slot.rule_violation`(其它槽位校验规则违规的兜底码) + +- `template.render_failed`(模板渲染失败) + +- `input.text_too_long`(输入超过 `A2AT_INPUT_TEXT_MAX_CHARS`) + +编程错误:text 或 templateUri 为 null 抛 `NullPointerException`;templateUri 为空白或非法抛 `IllegalArgumentException`。 + +**响应样例** + +```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 + +## 预期输出(Expected Output) +要求投诉诊断任务的结果包含如下信息: +1. 诊断结果;参数的取值范围包括:成功、失败;(必选) +2. 诊断结果详细信息; (必选) +3. 修复建议; (可选) +4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息; (可选) + +## 术语解释(Terminology Explanation) +1. 专线中断 + - 同义词术语:private line interruption,业务中断,网络无法连通,业务不通,业务无法访问 +2. 专线质差 + - 同义词术语:private line poor quality,业务卡顿,业务访问超时,业务丢包,业务时延大,业务抖动,业务拥塞,业务体验下滑,业务误码率高,业务光功率异常 +``` + +### 1.3.11 generateTaskPromptFromDataWithSchema + +**API定义** + +```java +public MetadataContent generateTaskPromptFromDataWithSchema( + Map data, Map schema, String templateUri) +``` + +**典型场景**:客户端Agent将上游系统的结构化任务参数(字段名可与模板槽位不同,由Schema描述字段语义)转换为Task-T协议报文,适合任务参数已结构化持有的场景。 + +**功能说明**:从结构化数据 + 语义 Schema 按指定 Task-T 模板生成任务提示词,**跳过场景识别**。`schema` 描述每个输入字段的业务含义(description / examples / enum 等),指导槽位填充与取值约束;每个 data 的 key 对应一个槽位值。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| data | Map<String, Object> | 是 | 结构化业务字段输入,key 为业务字段名,value 为字段值 | +| schema | Map<String, Object> | 是(非空) | 字段语义 JSON Schema,描述各字段含义与约束 | +| templateUri | String | 是 | 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_URI); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext))。 + +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,templateUri 为空白或非法或 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 + +## 预期输出(Expected Output) +要求投诉诊断任务的结果包含如下信息: +1. 诊断结果;参数的取值范围包括:成功、失败;(必选) +2. 诊断结果详细信息; (必选) +3. 修复建议; (可选) +4. 故障根因列表,每个故障根因包含故障根因名称、详细描述、修复建议、故障根因点位置等信息; (可选) + +## 术语解释(Terminology Explanation) +1. 专线中断 + - 同义词术语:private line interruption,业务中断,网络无法连通,业务不通,业务无法访问 +2. 专线质差 + - 同义词术语:private line poor quality,业务卡顿,业务访问超时,业务丢包,业务时延大,业务抖动,业务拥塞,业务体验下滑,业务误码率高,业务光功率异常 +``` + +### 1.3.12 validateTaskPromptAndDataFilling + +**API定义** + +```java +public FilledParamData validateTaskPromptAndDataFilling( + String prompt, Map schema, String templateUri) +``` + +**典型场景**:服务端Agent收到Task-T报文后、进入业务执行前的参数校验与提取入口;也是协商流程中"缺槽检测"的判定点——required参数缺失时由调用方决定是否发起协商补参。 + +**功能说明**:校验一条 Task-T 任务提示词报文是否匹配模板与槽位约束,并按调用方提供的 JSON Schema 提取参数。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| prompt | String | 是(非空) | 待校验的任务提示词报文文本(`MetadataContent.promptText()`);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | +| schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema,声明要提取/校验的参数及 required 约束 | +| templateUri | String | 是 | 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_URI) + .data(); +``` + +**输出说明** + +成功时返回 `FilledParamData`: + +| 字段/方法 | 类型 | 说明 | +| --------- | ---- | ---- | +| data() | Map<String, Object> | 按 Schema 提取的参数,key 为 Schema 中声明的参数名 | + +失败时抛 `ContentValidationException`(`A2ATError` 子类): + +| 成员 | 类型 | 说明 | +| ---- | ---- | ---- | +| getCode() | String | 机器可读错误码,取值见下 | +| getMessage() | String | 人类可读的失败描述 | +| errors() | List<SlotValidationError> | 逐槽位错误明细(槽位级错误码如 `content.param_missing`、`content.entry_field_missing`、`content.format_error`),结构见公共约定 | +| params() | Map<String, Object> | 被拒前的部分提取参数(无法提取的槽位值为 null) | + +错误码: + +- `negotiation.invalid_input`(prompt 为 null 或空白、schema 为 null,或 templateUri 前缀段/版本与该接口不符) + +- `negotiation.semantic_rejected`(语义校验拒绝,含 required 参数缺失或取值非法;`errors()` 中的逐槽位明细使用 `content.*` 码集,如 `content.param_missing`、`content.entry_field_missing`、`content.format_error`、`content.value_not_allowed`) + +- `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) + +- `template.not_found`(校验提示词资源缺失) + +- `input.text_too_long`(提示词超过 `A2AT_INPUT_TEXT_MAX_CHARS`) + +编程错误:templateUri 为 null 抛 `NullPointerException`,为空白/非法抛 `IllegalArgumentException`;prompt 为 null 或空白、schema 为 null 不属编程错误,以 `negotiation.invalid_input` 抛 `ContentValidationException`(见上错误码)。 + +**响应样例**(`faultTime`、`faultDetail` 为示例截断值) + +```text +extracted = +{ + accessPort=P781-珠江新城-PTN7900-23-TPA1EG24-17, + bizScenario=专线质差, + faultTime=2026-05-11, + eventSerialNo=event-id-20260511-09013, + faultDetail=320ms +} +``` + +校验拒绝时(缺少关键槽位的负例,来自 `TaskTDemoMain` 用例三): + +```text +ContentValidationException: [negotiation.semantic_rejected] ... + slot=任务对象 code=content.param_missing message=... facts={section_label=任务对象} +``` + +### 1.3.13 generateNotificationPromptFromText + +**API定义** + +```java +public MetadataContent generateNotificationPromptFromText(String text, String templateUri) +``` + +**典型场景**:客户端Agent将自然语言订阅需求(如业务抢通事件订阅)转换为指定场景的Notification-T订阅报文。 + +**功能说明**:从自然语言文本按指定 Notification-T 模板生成通知订阅提示词报文。执行一次 LLM 槽位提取后确定性渲染模板,生成阶段执行内置槽位 Schema 校验。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| text | String | 是 | 自然语言订阅描述(通知主题、订阅条件、上报通知数据格式等);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | +| templateUri | String | 是 | Notification-T 模板,如 `StandardTemplates.SUBSCRIBE_INCIDENT_URI`、`StandardTemplates.SERVICE_RECOVERY_URI` | + +**请求样例** + +```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")); +String templateUri = StandardTemplates.SERVICE_RECOVERY_URI; + +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`;templateUri 为空白或非法抛 `IllegalArgumentException`。 + +**响应样例**(按模板渲染,实际文本随 LLM 槽位提取结果变化;本例输入未指定订阅条件,对应槽位留空) + +```text +templateUri : Notification-T/network-layer/service-recovery/v1 +extensionUri: https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1 +promptText : +## 订阅描述 +请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。 + +## 通知主题 +业务抢通事件 + +## 订阅条件 + +## 上报通知数据格式 +1. 业务抢通方案执行状态,取值范围:未启动、已结束 +2. 投诉诊断任务流水号 +3. OSS侧事件流水号 +4. 接入端口名称 +5. 是否已授权OMC自动抢通,取值范围:是、否 +6. 业务抢通方案名称 +7. 业务抢通方案详情 + +## 预期输出 +1. 订阅结果,取值范围:成功 +2. 订阅成功后,按照<上报通知数据格式>上报消息 +``` + +### 1.3.14 generateNotificationPromptFromDataWithSchema + +**API定义** + +```java +public MetadataContent generateNotificationPromptFromDataWithSchema( + Map data, Map schema, String templateUri) +``` + +**典型场景**:客户端Agent将上游系统/界面提交的结构化订阅参数转换为Notification-T订阅报文,适合订阅参数已结构化持有的场景。 + +**功能说明**:从结构化数据 + 语义 Schema 按指定 Notification-T 模板生成通知订阅提示词。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| data | Map<String, Object> | 是 | 结构化订阅输入(如订阅条件、上报数据格式字段列表) | +| schema | Map<String, Object> | 是(非空) | 字段语义 JSON Schema | +| templateUri | String | 是 | 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`,templateUri 为空白或非法或 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. 业务抢通方案执行状态,取值范围:未启动、已结束(必选) +2. 投诉诊断任务流水号(必选) +3. OSS侧事件流水号(必选) +4. 接入端口名称(必选) +5. 是否已授权OMC自动抢通,取值范围:是、否(必选) +6. 业务抢通方案名称(必选) +7. 业务抢通方案详情(必选) +8. 业务抢通方案执行结束时间(可选) + +## 预期输出 +1. 订阅结果,取值范围:成功 +2. 订阅成功后,按照<上报通知数据格式>上报消息 +``` + +### 1.3.15 validateNotificationPromptAndDataFilling + +**API定义** + +```java +public FilledParamData validateNotificationPromptAndDataFilling( + String prompt, Map schema, String templateUri) +``` + +**典型场景**:服务端Agent收到Notification-T订阅报文后校验合规性并提取订阅参数(主题/条件/上报格式),据此建立订阅关系。 + +**功能说明**:校验一条 Notification-T 通知订阅提示词报文是否匹配模板与槽位约束,并按调用方 Schema 提取参数(订阅主题、订阅条件、上报通知数据格式等)。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| prompt | String | 是(非空) | 待校验的通知订阅提示词报文文本;输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | +| schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema | +| templateUri | String | 是 | 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)。错误码: + +- `negotiation.invalid_input`(prompt 为 null 或空白、schema 为 null,或 templateUri 前缀段/版本与该接口不符) + +- `negotiation.semantic_rejected`(必选参数缺失或取值非法;`errors()` 中的逐槽位明细使用 `content.*` 码集) + +- `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) + +- `template.not_found`(校验提示词资源缺失) + +- `input.text_too_long`(提示词超过 `A2AT_INPUT_TEXT_MAX_CHARS`) + +编程错误:templateUri 为 null 抛 `NullPointerException`,为空白/非法抛 `IllegalArgumentException`;prompt 为 null 或空白、schema 为 null 不属编程错误,以 `negotiation.invalid_input` 抛 `ContentValidationException`(见上错误码)。 + +**响应样例** + +```text +result.data() = +{ + topic=业务抢通事件, + subscriptionCondition=子网名称:xx子网, + notificationDataFormat=业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、业务抢通方案名称、业务抢通方案详情。 +} +``` + +### 1.3.16 generateAuthPromptFromText + +**API定义** + +```java +public MetadataContent generateAuthPromptFromText(String text, String templateUri) +``` + +**典型场景**:客户端Agent将自然语言授权诉求(新增/修改/删除/查询动网操作授权策略)转换为Authorization-T报文。 + +**功能说明**:从自然语言文本按指定 Authorization-T 模板生成授权策略操作提示词报文。执行一次 LLM 槽位提取后确定性渲染模板。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| text | String | 是 | 自然语言授权描述(操作类型 + 动网操作的授权策略内容);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | +| templateUri | String | 是 | Authorization-T 模板:`StandardTemplates.AUTHORIZATION_POLICY_MANAGEMENT_URI`(`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_URI); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`)。 + +失败时抛 `PromptGenerationException`(结构同 1.3.10),如操作类型不在"新增/修改/删除/查询授权策略"范围内时以 `slot.constraint_violated` 拒绝。编程错误:text 或 templateUri 为 null 抛 `NullPointerException`;templateUri 为空白或非法抛 `IllegalArgumentException`。 + +**响应样例** + +```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, String templateUri) +``` + +**典型场景**:客户端Agent将授权管理界面/系统按字段提交的结构化授权策略数据转换为Authorization-T报文。 + +**功能说明**:从结构化数据 + 语义 Schema 按指定 Authorization-T 模板生成授权策略操作提示词,跳过场景识别。语义约束同 1.3.11。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| data | Map<String, Object> | 是 | 结构化授权输入(操作类型、策略数量、策略详情列表等) | +| schema | Map<String, Object> | 是(非空) | 字段语义 JSON Schema | +| templateUri | String | 是 | 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_URI); +``` + +**输出说明** + +成功时返回 `MetadataContent`(结构同 [1.3.10](#1310-generatetaskpromptfromtext),`extensionUri` 为 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/Authorization-T/v1`)。 + +失败时抛 `PromptGenerationException`(结构同 1.3.10)。编程错误:入参为 null 抛 `NullPointerException`,templateUri 为空白或非法或 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, String templateUri) +``` + +**典型场景**:服务端Agent收到Authorization-T报文后校验合规性并提取操作类型与策略列表,据此执行授权策略管理动作。 + +**功能说明**:校验一条 Authorization-T 授权提示词报文是否匹配模板与槽位约束,并按调用方 Schema 提取参数(操作类型、策略列表等)。按各操作类型的字段要求做差异化校验:新增条目须含业务场景/处置类型/操作名称/有效期,修改条目须含策略标识与新有效期,删除条目可为策略标识或条件字段。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| prompt | String | 是(非空) | 待校验的授权提示词报文文本;输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | +| schema | Map<String, Object> | 是 | 调用方提供的参数 JSON Schema(含 `操作类型`、`策略列表` 等) | +| templateUri | String | 是 | 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_URI); +``` + +**输出说明** + +成功时返回 `FilledParamData`(结构同 [1.3.12](#1312-validatetaskpromptanddatafilling)),`data()` 为按 Schema 提取的参数(操作类型、策略列表等)。 + +失败时抛 `ContentValidationException`(结构同 1.3.12)。错误码: + +- `negotiation.invalid_input`(prompt 为 null 或空白、schema 为 null,或 templateUri 前缀段/版本与该接口不符) + +- `negotiation.semantic_rejected`(必选参数缺失或取值非法,如新增条目缺少必填字段(`content.entry_field_missing`)、有效期格式错误(`content.format_error`)) + +- `llm.invocation_failed` / `llm.response_invalid`(LLM 失败,可重试) + +- `template.not_found`(校验提示词资源缺失) + +- `input.text_too_long`(提示词超过 `A2AT_INPUT_TEXT_MAX_CHARS`) + +编程错误:templateUri 为 null 抛 `NullPointerException`,为空白/非法抛 `IllegalArgumentException`;prompt 为 null 或空白、schema 为 null 不属编程错误,以 `negotiation.invalid_input` 抛 `ContentValidationException`(见上错误码)。 + +**响应样例** + +```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 识别业务场景,再按场景对应的内置模板完成槽位提取与渲染。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| userInput | Object | 是 | 任务描述:`String`(自然语言)或 `Map`(结构化输入),统一经 LLM 抽取;入参为 `String` 时输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | + +**请求样例** + +```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`(场景识别未命中)、`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`(模板加载/渲染) | + +**响应样例** + +成功时: + +```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=输入内容无法匹配任何已知场景:<原因>, stage=scenario } +``` + +### 1.3.20 checkTaskPrompt + +**API定义** + +```java +public PromptComplianceResult checkTaskPrompt(String processedPromptText) +``` + +**典型场景**:服务端Agent对收到的任务报文做协议完备性校验(场景/模板/槽位合规),只需通过与失败结论、无需提取参数的场景。 + +**功能说明**:通用任务提示词合规校验入口(服务端):对客户端下发的 processed task prompt 执行场景匹配、模板遵从性校验与槽位校验。与 `validateTaskPromptAndDataFilling` 的区别:本接口不提取参数、不接收调用方 Schema,仅给出通过与失败的标准化结论。 + +**输入说明** + +| 参数 | 类型 | 必填 | 说明 | +| ---- | ---- | ---- | ---- | +| processedPromptText | String | 是 | 客户端下发的 A2A-T 协议报文文本(`Message.metadata` 中以扩展 URI 为 key 的值);输入长度受配置项 `A2AT_INPUT_TEXT_MAX_CHARS` 限制,默认值为 16384 | + +**请求样例** + +```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 | 机器可读错误码,取值来自错误码列表:`scenario.not_matched`(报文解析/场景识别失败)、`template.not_found`(模板缺失)、槽位域码如 `slot.not_provided`(必填缺失)、`slot.constraint_violated`(取值越界)、`slot.rule_violation`(其它槽位规则违规)、`input.text_too_long`(输入长度防护) | +| message | String | 人类可读的失败描述 | +| stage | String | 失败发生的阶段:`input_gate`(输入长度防护)、`prompt_parse`(报文解析)、`generation`(模板加载)、`slot_validation`(槽位校验) | + +**响应样例** + +成功时: + +```text +result.success() = true +``` + +失败时: + +```text +result.success() = false +result.failure() = +{ code=slot.not_provided, message=输入中未提供「任务对象」。, stage=slot_validation } +``` + +## 1.4 错误码列表 + +**错误码分类**:BUSINESS = 调用方可行动的预期业务失败,由 `A2ATBusinessException` 子类携带;INFRA = 基础设施失败,由普通 `A2ATError` 携带。 + +| 错误码 | 类别 | 消息(zh-CN) | 消息(en-US) | +| ----------------------------------------- | -------- | ------------------------------------------------------------ | ------------------------------------------------------------ | +| `template.not_found` | BUSINESS | 模板「{template_uri}」不支持语言「{language}」,请检查模板标识与语言配置 | Template '{template_uri}' does not support language '{language}'; check the template URI and language setting | +| `template.render_failed` | BUSINESS | 模板「{template_uri}」渲染失败:{reason} | Failed to render template '{template_uri}': {reason} | +| `template.load_failed` | INFRA | 模板资源「{resource_path}」读取失败 | Failed to read template resource '{resource_path}' | +| `slot.schema_not_found` | BUSINESS | 模板「{template_uri}」缺少参数定义文件(语言「{language}」) | Template '{template_uri}' is missing its slot schema (language '{language}') | +| `slot.not_provided` | BUSINESS | 输入中未提供「{slot_label}」。 | '{slot_label}' is not provided in the input. | +| `slot.constraint_violated` | BUSINESS | 「{slot_label}」的取值「{actual}」不在允许范围内 | The value of '{slot_label}' ({actual}) is not within the allowed range | +| `slot.semantic_conflict` | BUSINESS | 「{slot_label}」的取值与参数定义冲突:{reason} | The value of '{slot_label}' conflicts with the slot definition: {reason} | +| `slot.fabricated_value` | BUSINESS | 「{slot_label}」的取值「{actual}」是占位内容,不是有效值 | The value of '{slot_label}' ({actual}) is placeholder content, not a valid value | +| `slot.cross_scenario_pollution` | BUSINESS | 「{slot_label}」的取值混入了其他场景的内容 | The value of '{slot_label}' contains content from a different scenario | +| `slot.insufficient_grounding` | BUSINESS | 「{slot_label}」的取值缺少充分依据 | The value of '{slot_label}' lacks sufficient grounding | +| `slot.rule_violation` | BUSINESS | 「{slot_label}」的取值不符合校验规则。 | The value of '{slot_label}' violates the validation rules. | +| `input.text_too_long` | BUSINESS | 输入文本长度 {actual_length} 超过上限 {max_chars}(A2AT_INPUT_TEXT_MAX_CHARS) | Input text length {actual_length} exceeds the maximum of {max_chars} (A2AT_INPUT_TEXT_MAX_CHARS) | +| `content.param_missing` | BUSINESS | 「{section_label}」未填写,请补充该参数的取值 | '{section_label}' is empty; please provide a value | +| `content.entry_field_missing` | BUSINESS | 「{section_label}」第 {index} 条缺少必填字段「{field_label}」 | Entry {index} of '{section_label}' is missing required field '{field_label}' | +| `content.format_error` | BUSINESS | 「{section_label}」的取值格式不符合要求:{reason} | The format of '{section_label}' is invalid: {reason} | +| `content.value_not_allowed` | BUSINESS | 「{section_label}」的取值「{actual}」不在允许范围内 | The value of '{section_label}' ({actual}) is not allowed | +| `content.semantic_conflict` | BUSINESS | 「{section_label}」存在语义冲突:{reason} | '{section_label}' has a semantic conflict: {reason} | +| `content.rule_violation` | BUSINESS | 「{section_label}」的取值不符合校验规则。 | The value of '{section_label}' violates the validation rules. | +| `scenario.not_matched` | BUSINESS | 输入内容无法匹配任何已知场景:{reason} | The input does not match any known scenario: {reason} | +| `llm.not_configured` | BUSINESS | 未配置 LLM 客户端,无法执行该操作(请检查 A2AT_LLM_* 配置) | No LLM client is configured; check the A2AT_LLM_* settings | +| `llm.invocation_failed` | BUSINESS | LLM 调用失败(提供方 {provider}):{reason} | LLM invocation failed (provider {provider}): {reason} | +| `llm.response_invalid` | BUSINESS | LLM 返回内容不符合要求({step} 步骤),请重试 | The LLM response is invalid (step: {step}); please retry | +| `negotiation.invalid_input` | BUSINESS | 输入的协商内容无效:{reason} | The negotiation input is invalid: {reason} | +| `negotiation.invalid_context_id` | BUSINESS | 协商上下文标识「{actual}」不是合法的 UUID | The negotiation context id '{actual}' is not a valid UUID | +| `negotiation.round_exceeded` | BUSINESS | 协商轮次 {round} 已超过上限 {max_rounds} | Negotiation round {round} exceeds the maximum of {max_rounds} | +| `negotiation.type_mismatch` | BUSINESS | 报文内容属于「{implied}」协商,与声明的模板类型「{declared}」不符 | The message implies '{implied}' negotiation but the declared template type is '{declared}' | +| `negotiation.phase_mismatch` | BUSINESS | 报文阶段与声明的模板阶段不符({implied} vs {declared}) | The message phase does not match the declared template phase ({implied} vs {declared}) | +| `negotiation.conclusion_mismatch` | BUSINESS | 报文结论为「{actual}」,与该方法的预期「{expected}」不符 | The message conclusion is '{actual}' but '{expected}' is expected for this method | +| `negotiation.content_invalid` | BUSINESS | 协商内容字段「{field}」无效:{reason} | The negotiation content field '{field}' is invalid: {reason} | +| `negotiation.field_missing` | BUSINESS | 协商报文缺少必填字段「{field}」 | The negotiation message is missing required field '{field}' | +| `negotiation.content_extract_failed` | BUSINESS | 无法从文本提取协商内容({field}):{reason} | Failed to extract negotiation content from text ({field}): {reason} | +| `negotiation.conclusion_content_mismatch` | BUSINESS | 结论为「{conclusion}」,但「{section_label}」未表达该结论应携带的内容 | The conclusion is '{conclusion}' but '{section_label}' does not state the content the conclusion requires | +| `negotiation.missing_result_content` | BUSINESS | 「{section_label}」板块缺少结论应携带的内容 | The '{section_label}' section is missing the content required by its conclusion | +| `negotiation.mutually_exclusive_sections` | BUSINESS | 互斥板块同时出现:{sections} | Mutually exclusive sections appear together: {sections} | +| `negotiation.constraint_conflict` | BUSINESS | 「{section_label}」与既有约束冲突:{reason} | '{section_label}' conflicts with existing constraints: {reason} | +| `negotiation.field_inconsistency` | BUSINESS | 「{section_label}」内字段取值前后不一致:{reason} | Fields within '{section_label}' are inconsistent: {reason} | +| `negotiation.invalid_time_interval` | BUSINESS | 「{section_label}」的时间区间不合法(开始时间不得晚于结束时间) | The time interval of '{section_label}' is invalid (start must not be later than end) | +| `negotiation.semantic_rejected` | BUSINESS | 协商报文语义校验未通过 | The negotiation message failed semantic validation | +| `negotiation.rule_violation` | BUSINESS | 「{section_label}」不符合协商报文的校验规则。 | '{section_label}' violates the negotiation message validation rules. | +| `infra.config_invalid` | INFRA | 配置项「{key}」无效:{reason} | Invalid configuration '{key}': {reason} | +| `infra.resource_read_failed` | INFRA | 资源「{resource_path}」读取失败 | Failed to read resource '{resource_path}' | +| `infra.internal_error` | INFRA | SDK 内部错误,请联系维护方并提供上下文 | SDK internal error; contact the maintainer with context | diff --git "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" index 2294a2e8..a44a63a3 100644 --- "a/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/zh/\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -1,55 +1,290 @@ # 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协议推出的电信领域多智能体互联协议,专为电信领域复杂协同场景设计。 + +电信领域的业务场景复杂、要求高,运维智能体互联和协同需要有专门的协议进行支撑。A2A-T方案基于A2A协议,重点针对电信领域业务流相关的信息模型、任务协商、协作安全等增强能力进行应用扩展。 + +a2a-t-sdk-java是面向电信智能体协作场景的 Java SDK,用于在 A2A-T 交互中生成、校验和协商任务提示词。SDK 适合客户端 Agent、服务端 Agent 以及上层编排系统集成使用。 + +主要能力包括: + +- **客户端模板报文生成**:客户端根据自然语言或结构化输入生成符合 A2A-T 格式的协议报文。 +- **服务端模板报文校验**:服务端校验客户端下发的 A2A-T协议格式报文 是否匹配场景、模板和槽位约束。 +- **多轮协商管理**:支持`information`、`feasibility`、`target`协商流程。 +- **模板资源管理**:内置场景、槽位、模板和系统提示词资源,支持`classpath`内置资源与`local_file`本地文件两种资源加载方式。 +- **LLM 适配**:通过 OpenAI-compatible 调用链接入外部大模型。 + +SDK提供的API列表和使用说明,详见 [API参考.md](API参考.md)。 + +### 1.1.2 A2A-T SDK与A2A SDK的关系 + +A2A-T协议是基于A2A协议的扩展,针对协议扩展的内容提供了A2A-T SDK,支撑快速构建用于电信领域复杂协同场景的智能体。 + +A2A-T SDK独立于A2A SDK,通过同时集成A2A-T SDK与A2A SDK可以构建支持A2A-T协议的agent,支持电信领域多agent之间的确定性、高可靠、高效及安全协同。Java生态中可搭配的A2A SDK为a2a-java(`org.a2aproject.sdk`)。 + +```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 + + 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 + + Client -- HTTPs A2A-T请求--> Server + Server -- HTTPs A2A-T响应--> Client + +``` + +### 1.1.3 A2A-T SDK典型交互场景 + +一个典型的多智能体协作交互场景至少涉及到三个组件:客户端Agent、服务端Agent、注册中心; + +各组件间关系示例如下: + +```mermaid +flowchart TD + subgraph Client["客户端 Agent"] + A1["A2A-T Client SDK

      Prompt模板生成:generate*PromptFromText"] + A2["A2A Client SDK

      使用HTTP/REST协议发送请求"] + A1 --> A2 + end + + subgraph Server["服务端 Agent"] + B1["A2A-T Server SDK

      遵从性校验和提参:validate*AndDataFilling"] + B2["A2A Server SDK

      接收请求"] + B2 --> B1 + end + + Registry["注册中心 (registry-center)"] + + A2 -- HTTPs --> B2 + Client -. 注册 / 检索 .-> Registry + Server -. 注册 / 检索 .-> Registry +``` + + ## 1.2 约束与限制 -1. JDK 要求为 17+。 -2. 当前支持 `classpath` 和 `local_file` 提示词资源,默认从 jar 内置资源加载。 -3. 默认支持 `local_rule` 和 `openai_compatible` LLM provider。 -4. 协商状态存储当前支持 `in_memory`。 -5. SDK 不负责启动业务 HTTP 服务、用户认证、密钥管理和注册中心部署。 +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`(本地文件资源)两种来源;`local_file` 模式下业务内容本地优先、缺失回退内置(overlay)。 +4. 协商状态存储仅提供 `in_memory`,进程退出后状态丢失。 +5. A2A-T SDK 不提供 Agent HTTP 服务框架、注册中心客户端、鉴权和密钥管理能力,需由业务系统集成。 ## 1.3 环境准备 -### 1.3.1 获取源码 +### 1.3.1 环境要求 -```bash -git clone git@github.com:project-openan/a2a-t-sdk-java.git -cd a2a-t-sdk-java +| 项目 | 要求 | +| -------- | ---------------------------------------- | +| Java | JDK 17+ | +| 构建工具 | Maven 3.8+ | +| LLM | 需要可访问的 OpenAI 服务及 API Key | +| 操作系统 | Linux、Windows、macOS 均可用于开发和联调 | + +### 1.3.2 搭建环境 + +以Windows11 64位 amd64 开发环境为例 + +**安装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` 并解压到本地目录 + +2. 配置环境变量:`MAVEN_HOME` 指向解压目录,并将 `%MAVEN_HOME%\bin` 加入 `PATH` + +3. 安装完成,输入校验命令: + + ```shell + mvn -version + # 预期输出:Apache Maven 3.9.x + ``` + +## 1.4 基础开发样例 + +目的:通过提供A2A-T客户端和服务端的样例代码,帮助使用者快速、规范地熟悉SDK接入流程,加速功能开发和上线落地。 + +### 1.4.1 样例接口说明 + +本基础开发样例主要使用到A2A-T SDK如下两个API,实际开发过程中需按业务实际诉求选择不同API使用: + +**1、A2A-T Client SDK** + +接口定义及功能描述:从自然语言文本按指定 Notification-T 模板生成通知订阅提示词报文(跳过场景识别)。执行一次 LLM 槽位提取后确定性渲染模板,生成阶段执行内置槽位 Schema 校验。 + +```java +public MetadataContent generateNotificationPromptFromText(String text, String templateUri) ``` -### 1.3.2 编译工程 +接口调用样例: -```bash -mvn -DskipTests package +```java +import java.nio.file.Path; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; + +A2ATClient client = new A2ATClient(Path.of("client.env")); + +MetadataContent result = client.generateNotificationPromptFromText( + "我想订阅一下业务抢通事件。上报通知数据格式如下:" + + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;" + + "2. 投诉诊断任务流水号;3. OSS侧事件流水号;4. 接入端口名称;" + + "5. 是否已授权OMC自动抢通,取值范围:是、否;6. 业务抢通方案名称;7. 业务抢通方案详情", + "Notification-T/network-layer/service-recovery/v1"); + +/*输出生成的通知订阅提示词报文(result.promptText(),实际文本随 LLM 槽位提取结果变化;本例未提供订阅条件,该槽位对应章节留空) +## 订阅描述 +请根据以下 <通知主题>、<订阅条件>、<上报通知数据格式> 及 <预期输出> 信息,完成网络侧业务抢通事件的订阅与上报任务。 + +## 通知主题 +业务抢通事件 + +## 订阅条件 + +## 上报通知数据格式 +1. 业务抢通方案执行状态,取值范围:未启动、已结束 +2. 投诉诊断任务流水号 +3. OSS侧事件流水号 +4. 接入端口名称 +5. 是否已授权OMC自动抢通,取值范围:是、否 +6. 业务抢通方案名称 +7. 业务抢通方案详情 + +## 预期输出 +1. 订阅结果,取值范围:成功 +2. 订阅成功后,按照<上报通知数据格式>上报消息 +*/ ``` -### 1.3.3 运行测试 +**2、A2A-T Server SDK** -```bash -mvn test +接口定义及功能描述:校验一条 Notification-T 通知订阅提示词报文是否匹配模板与槽位约束,并按调用方 Schema 提取参数(订阅主题、订阅条件、上报通知数据格式等)。 + +```java +public FilledParamData validateNotificationPromptAndDataFilling( + String prompt, Map schema, String templateUri) ``` -### 1.3.4 格式检查 +接口调用样例:校验客户端发来的通知订阅报文并提取订阅参数 + +```java +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.server.A2ATServer; -工程使用 Spotless 管理 Java 格式: +A2ATServer server = new A2ATServer(Path.of("server.env")); -```bash -mvn spotless:check +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, "Notification-T/network-layer/service-recovery/v1"); + +/*按 Schema 提取的订阅参数(result.data(),订阅条件为空表示本例输入未携带订阅条件) +{ + topic=业务抢通事件, + subscriptionCondition=, + notificationDataFormat=业务抢通事件数据包含:业务抢通方案执行状态(未启动、已结束)、投诉诊断任务流水号、OSS侧事件流水号、接入端口名称、是否已授权OMC自动抢通(是、否)、业务抢通方案名称、业务抢通方案详情。 +} +*/ ``` -如需自动格式化: +### 1.4.2 开发流程 -```bash -mvn spotless:apply +- 客户端开发流程 + +```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扩展发送请求"] ``` -## 1.4 Maven 引用 +> 二次开发指已基于A2A开发Agent,并对接过注册中心 -业务项目可使用 BOM 管理版本: +- 服务端开发流程: + +```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.4.3 样例客户端开发步骤 + +#### Step1 安装依赖 + +业务项目可使用 BOM 统一管理 A2A-T SDK 版本: ```xml @@ -57,7 +292,7 @@ mvn spotless:apply net.openan.a2a-t.sdk a2a-t-bom - 1.0.0 + 1.1.1 pom import @@ -68,319 +303,1492 @@ mvn spotless:apply 客户端 Agent 引用: ```xml + net.openan.a2a-t.sdk a2a-t-client -``` - -服务端 Agent 引用: -```xml + - net.openan.a2a-t.sdk - a2a-t-server + org.a2aproject.sdk + a2a-java-sdk-client + 1.0.0.Beta1 -``` - -如果业务系统需要直接读取 SDK 内置资源,可额外引用: - -```xml - net.openan.a2a-t.sdk - a2a-t-resources + org.a2aproject.sdk + a2a-java-sdk-client-transport-rest + 1.0.0.Beta1 ``` -## 1.5 配置加载 +> 本指南样例中,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 +> +> ``` -Java SDK 不自动发现 `.env`,调用方必须显式传入路径: +#### Step2 配置大模型 -```java -import java.nio.file.Path; -import net.openan.a2at.sdk.client.A2ATClient; - -A2ATClient client = new A2ATClient(Path.of("client.env")); -``` - -基础配置示例: +复制仓库根目录`env.example`文件内容到`client.env`中(服务端对应`server.env`),配置如下内容 ```properties +# Controls the language used by prompt runtime components. A2AT_LANGUAGE=zh-CN + +# Selects the prompt source backend (classpath | local_file). A2AT_PROMPT_SOURCE_TYPE=classpath + +# Required when A2AT_PROMPT_SOURCE_TYPE=local_file A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible + +# Selects the LLM provider used by the shared client. +A2AT_LLM_PROVIDER=openai + +# Selects the default model name for the configured provider. A2AT_LLM_MODEL=deepseek-chat + +# API key for the configured LLM provider. +A2AT_LLM_API_KEY={your_llm_api_key} + +# Base URL for the configured LLM provider. A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY={your_api_key} + +# Selects the negotiation state store implementation. A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + +# Input limits +A2AT_INPUT_TEXT_MAX_CHARS=16384 ``` +> `A2AT_LLM_API_KEY` 是**调用外部大模型**使用的密钥,请妥善保存 +> +> SDK 通过 OpenAI-compatible 接入外部大模型,`A2AT_LLM_PROVIDER` 当前仅支持 `openai`;接入 DeepSeek 等 OpenAI 兼容服务时,通过 `A2AT_LLM_BASE_URL` 指定服务地址、`A2AT_LLM_MODEL` 指定模型名。 +> -## 1.6 SDK 基本用法 +#### Step3 初始化AgentCard -### 1.6.1 客户端生成任务提示词 +a2a-java 官方推荐使用 Builder 构造 `AgentCard`(`org.a2aproject.sdk.spec.AgentCard`),可在 `capabilities.extensions` 中声明支持的 A2A-T 模板;注册到注册中心时,AgentCard 序列化为 JSON(外层包装为 `{"agentCards": [...]}`)。 + +- 客户端样例AgentCard定义参考(可在`extensions`中声明支持的A2A-T模板): + +```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_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; +private static final String NOTIFICATION_T_EXT_URI = + "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_URI) + .description("Extension of structured prompt Task-T requests.") + .build(), + AgentExtension.builder() + .uri(NOTIFICATION_T_EXT_URI) + .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" + ] + } + ] + } + ] +} +``` + +#### Step4 AgentCard注册与检索 + +- **AgentCard注册**:将客户端AgentCard发布到注册中心,注册中心的地址和URI以实际部署情况为准 + +```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` 和支持的技能 + +```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 模型: + +```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); +} + +private static List stringList(Object value) { + return value instanceof List values ? values.stream().map(String::valueOf).toList() : List.of(); +} +``` + +#### Step5 初始化A2A-T客户端 ```java import java.nio.file.Path; 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"); - -if (result.success()) { - System.out.println(result.promptText()); -} else { - System.out.println(result.failure().message()); -} ``` -### 1.6.2 服务端校验任务提示词 +`A2ATClient` 与 `A2ATServer` 均在构造时显式传入 `.env` 文件路径(Java SDK 不自动发现配置文件),路径相对于进程工作目录解析,建议使用绝对路径。 + +#### Step6 生成A2A-T模板报文 + +客户端使用A2A-T Client SDK API `generateNotificationPromptFromText`生成 Notification-T 订阅报文(processed_prompt),再将其作为 A2A 消息的一部分发送给目标 agent。 ```java import java.nio.file.Path; -import net.openan.a2at.sdk.server.A2ATServer; -import net.openan.a2at.sdk.server.model.PromptComplianceResult; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.model.MetadataContent; -A2ATServer server = new A2ATServer(Path.of("server.env")); -PromptComplianceResult result = server.checkTaskPrompt(processedPromptText); +A2ATClient client = new A2ATClient(Path.of("client.env")); + +// 生成 A2A-T 通知订阅提示词报文(生成失败时抛出PromptGenerationException,携带code、message) +MetadataContent result = client.generateNotificationPromptFromText( + "我想订阅一下业务抢通事件。上报通知数据格式如下:" + + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;" + + "2. 投诉诊断任务流水号;3. OSS侧事件流水号;4. 接入端口名称;" + + "5. 是否已授权OMC自动抢通,取值范围:是、否;6. 业务抢通方案名称;7. 业务抢通方案详情", + "Notification-T/network-layer/service-recovery/v1"); + +String processedPrompt = result.promptText(); +String extensionUri = result.extensionUri(); // A2A-T扩展URI,用于消息metadata与请求头 +``` -if (result.success()) { - System.out.println("prompt check passed"); -} else { - System.out.println(result.failure().message()); +#### Step7 请求头激活A2A-T扩展 + +A2A 协议通过 HTTP Header 传递协议版本与扩展声明,需使用如下Header + +| Header | 方向 | 必填 | 取值 | +| ---------------- | ------ | --------------- | ------------------------------------------------- | +| `A2A-Version` | 请求头 | 是 | 协议版本,如 `1.0`(客户端必须随每个请求携带) | +| `A2A-Extensions` | 请求头 | 使用A2A-T时必填 | 逗号分隔的扩展 URI 列表,声明本次请求要使用的扩展 | + +客户端请求头填写样例:使用 a2a-java 发送请求时,`A2A-Extensions` 通过 `ClientCallContext` 的 headers 传入(协议版本等协议头由 a2a-java 传输层按协议要求携带) + +```java +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"; + +ClientCallContext callContext = + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", notificationPromptExt)); +``` + +#### Step8 携带A2A-T扩展发送请求 + +使用 a2a-java 官方 SDK 发送请求:processed prompt 放入 `Message.metadata`(以扩展 URI 为 key),请求头通过 `ClientCallContext` 声明,由 `Client` 经 REST 传输发送。 + +```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.core.model.MetadataContent; +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.3 Step6) +A2ATClient client = new A2ATClient(Path.of("client.env")); +MetadataContent result = client.generateNotificationPromptFromText( + "我想订阅一下业务抢通事件。上报通知数据格式如下:" + + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;" + + "2. 投诉诊断任务流水号;3. OSS侧事件流水号;4. 接入端口名称;" + + "5. 是否已授权OMC自动抢通,取值范围:是、否;6. 业务抢通方案名称;7. 业务抢通方案详情", + "Notification-T/network-layer/service-recovery/v1"); +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("我想订阅一下业务抢通事件")) + .metadata(Map.of(notificationPromptExt, processedPrompt)) + .build(); +MessageSendParams params = MessageSendParams.builder().message(message).build(); + + // 3) 通过 ClientCallContext 声明A2A-T扩展请求头(见 1.4.3 Step7) +ClientCallContext callContext = + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", notificationPromptExt)); + +// 4) 使用 a2a-java Client 发送请求并消费任务事件 +CountDownLatch done = new CountDownLatch(1); + try (Client a2aClient = Client.builder(agentCard) + .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(); +} + +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.6.3 协商接口 +#### 样例客户端完整代码 ```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.negotiation.types.model.NegotiationType; +import net.openan.a2at.sdk.core.model.MetadataContent; +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_T_EXT_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final ObjectMapper MAPPER = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL); + 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.3 Step3 定义的客户端AgentCard + AgentCard serverAgentCard = toAgentCard( + discoverAgent("http://{ip:port}", "Huawei", "RAN Domain Agent")); + + // 2) 使用SDK能力生成 A2A-T 通知订阅提示词报文 + A2ATClient client = new A2ATClient(Path.of("client.env")); + MetadataContent result = client.generateNotificationPromptFromText( + "我想订阅一下业务抢通事件。上报通知数据格式如下:" + + "1. 业务抢通方案执行状态,取值范围:未启动、已结束;" + + "2. 投诉诊断任务流水号;3. OSS侧事件流水号;4. 接入端口名称;" + + "5. 是否已授权OMC自动抢通,取值范围:是、否;6. 业务抢通方案名称;7. 业务抢通方案详情", + "Notification-T/network-layer/service-recovery/v1"); + 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("我想订阅一下业务抢通事件")) + .metadata(Map.of(NOTIFICATION_T_EXT_URI, processedPrompt)) + .build(); + MessageSendParams params = MessageSendParams.builder().message(message).build(); + ClientCallContext callContext = + new ClientCallContext(Map.of(), Map.of("A2A-Extensions", NOTIFICATION_T_EXT_URI)); + + // 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); + } +} +``` -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"))); +> `buildClientAgentCard()`、`toAgentCard(Map)` 及 `string`/`stringList` 辅助方法见 1.4.3 Step3 与 Step4。 + +### 1.4.4 样例服务端开发步骤 + +#### Step1-Step5 前置步骤 + +安装依赖、配置大模型等步骤均可以参考[客户端实现](#143-样例客户端开发步骤),差异化的内容如下: + +服务端 Agent 推荐使用 a2a-java 官方的 REST 参考服务(基于 Quarkus,自动装配 REST 传输、任务管理与事件队列)承载 A2A 请求,引用如下依赖: + +```xml + + + net.openan.a2a-t.sdk + a2a-t-server + + + + + org.a2aproject.sdk + a2a-java-sdk-reference-rest + 1.0.0.Beta1 + + + + + com.fasterxml.jackson.core + jackson-databind + 2.20.1 + +``` + +Quarkus 工程还需引入 Quarkus 平台 BOM 与构建插件(完整工程脚手架可参考 a2a-java 官方 [Server Guide](https://a2aproject.github.io/a2a-java/) 或 https://code.quarkus.io 生成): + +```xml + + 3.30.6 + + + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus.platform + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + generate-code-tests + + + + + + ``` -业务系统需要把协商 payload 中的文本和上下文随 A2A 消息传递给对端,后续通过 `receiveNegotiation` 和 `continueNegotiation` 推进状态。 +- 初始化A2A-T服务端 +```java +import java.nio.file.Path; +import net.openan.a2at.sdk.server.A2ATServer; -## 1.7 完整集成开发流程 +A2ATServer server = new A2ATServer(Path.of("server.env")); +``` -### 1.7.1 客户端 Agent +- 初始化AgentCard:a2a-java 官方推荐通过 CDI Producer 暴露 `@PublicAgentCard` 限定的 AgentCard(使用 Builder 构造,构造方式参考 1.4.3 Step3),服务端样例AgentCard定义参考: -客户端 Agent 推荐流程: +```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_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; +private static final String NOTIFICATION_T_EXT_URI = + "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_URI) + .description("Extension of structured prompt TASK-T requests.") + .required(false) + .build(), + AgentExtension.builder() + .uri(NOTIFICATION_T_EXT_URI) + .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(); +} +``` -1. 读取 `.env` 并创建 `A2ATClient`。 -2. 根据用户输入调用 `generateTaskPrompt`。 -3. 将 prompt 放入 A2A 消息正文或扩展字段。 -4. 通过注册中心查询目标 AgentCard。 -5. 根据 AgentCard 中的接口地址发起 A2A 请求。 -6. 如果收到协商上下文,通过 `receiveNegotiation` 和 `continueNegotiation` 继续交互。 +- 服务端样例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请根据以下 \<通知主题\>、\<订阅条件\>、\<上报通知数据格式\> 及 \<预期输出\> 信息,完成网络侧智能故障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" + } + ] + } + ] +} +``` -`a2a-t-sample` 中 `ClientSampleFlow` 展示了完整流程:读取场景、查询 AgentCard、生成 prompt、构造 A2A 请求并处理流式事件。 +#### Step6-Step7 服务端接收报文并校验 -### 1.7.2 服务端 Agent +使用 a2a-java 承载服务端时,协议解析、任务创建与事件队列管理由 SDK 完成,业务侧实现官方的 `AgentExecutor` 接口:在 `execute` 回调中,从消息 `metadata` 字段(以扩展 URI 为 key)取出 Notification-T 订阅报文,传递给 `A2ATServer.validateNotificationPromptAndDataFilling` 校验并提取订阅参数,并通过 `AgentEmitter` 推送任务状态与结果事件: -服务端 Agent 推荐流程: +```java +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.openan.a2at.sdk.core.model.FilledParamData; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +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; +import org.a2aproject.sdk.spec.TextPart; + +public final class ServiceRecoverySubscriptionExecutor implements AgentExecutor { + + private static final String NOTIFICATION_T_EXT_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final String NOTIFICATION_TEMPLATE_URI = + "Notification-T/network-layer/service-recovery/v1"; + + private static final Map NOTIFICATION_PARAM_SCHEMA = Map.of( + "type", "object", + "properties", Map.of( + "topic", Map.of("type", "string", "description", "订阅主题(必选)。订阅的事件主题名称。"), + "subscriptionCondition", Map.of( + "type", "string", "description", "订阅条件(可选)。待订阅的条件描述。"), + "notificationDataFormat", Map.of( + "type", "string", "description", "上报通知数据格式(必选)。待上报的通知数据格式描述。")), + "required", List.of("topic", "notificationDataFormat")); + + private final A2ATServer server; + + public ServiceRecoverySubscriptionExecutor(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 notification prompt")); + return; + } + agentEmitter.submit(statusMessage(requestContext, + "Subscription accepted, starting service recovery event reporting task")); + + // 2) 使用SDK能力校验Notification-T订阅报文并按Schema提取订阅参数 + Map filledParams; + try { + FilledParamData checkResult = server.validateNotificationPromptAndDataFilling( + processedPrompt, NOTIFICATION_PARAM_SCHEMA, NOTIFICATION_TEMPLATE_URI); + filledParams = checkResult.data(); + } catch (ContentValidationException error) { + // 校验失败,exception 携带 code、message 与 errors() 逐槽位明细 + agentEmitter.reject(statusMessage(requestContext, + "Notification prompt validation failed: " + error.getCode() + ": " + error.getMessage())); + return; + } + + // 3) 校验通过,按提取的订阅参数(topic/subscriptionCondition/notificationDataFormat)建立订阅并上报 + // 业务抢通事件(事件字段取自样例 a2a-t-sample/.../service-recovery/server/mock-event-data.json, + // “业务抢通方案详情”字段较长,此处省略) + agentEmitter.startWork(statusMessage(requestContext, + "Subscribed to [" + filledParams.get("topic") + "], service recovery event reporting in progress")); + agentEmitter.addArtifact( + List.of(new DataPart(Map.of( + "serviceManagement.ServiceRecoveryEvent", Map.of( + "业务抢通方案执行状态", "已结束", + "投诉诊断任务流水号", "9de168c0-6179-4778-8b72-4279582c0a3f", + "OSS侧事件流水号", "event-id-202606250128", + "接入端口名称", "P781-SZ-PTN7900-23-TPA1EG24-17", + "是否已授权OMC自动抢通", "是", + "业务抢通方案名称", "隧道调优", + "业务抢通方案执行结束时间", "2026-05-11T08:21:46Z")))), + "serviceManagement.ServiceRecoveryEvent", + "Service recovery event artifact", + null); + agentEmitter.complete(statusMessage(requestContext, + "Service recovery notification reporting finished, subscription task 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_T_EXT_URI); + 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(); + } +} +``` -1. 创建业务 HTTP 服务或接入已有 A2A Java 服务框架。 -2. 启动时构造 AgentCard 并注册到注册中心。 -3. 收到 A2A 请求后提取 processed task prompt。 -4. 调用 `A2ATServer.checkTaskPrompt`。 -5. 校验通过后执行业务逻辑。 -6. 校验失败或信息不足时,通过协商接口返回补充信息要求。 +#### Step8 填充A2A响应头,返回响应 -`a2a-t-sample` 中 `ServerSampleMain` 和 `ServerSampleFlow` 展示了 AgentCard 注册、HTTP 服务启动、prompt 提取、校验和任务事件推送。 +| Header | 方向 | 必填 | 取值 | +| ---------------- | ------ | --------------- | ----------------------------------------- | +| `A2A-Extensions` | 响应头 | 使用A2A-T时必填 | 服务端实际参与的扩展 URI 列表(逗号分隔) | -## 1.8 提示词资源扩展 -### 1.8.1 文件扩展 -自定义场景时,按以下结构准备本地资源: +使用 a2a-java REST 传输承载服务端时,协议层(请求的协议版本与扩展声明校验、任务与事件管理、SSE 流式响应)由 SDK 传输层处理,业务侧通过 `AgentEmitter` 推送任务状态与结果事件即可;若业务系统自行实现 HTTP 承载,需按上表在响应中回填 `A2A-Extensions` 头。 -```text -prompt_resources/ - scenarios/zh-CN/scenarios.json - slots/{scenario_code}/zh-CN/slot.json - templates/{scenario_code}/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 +#### 样例服务端完整代码 + +服务端通过 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 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.core.model.FilledParamData; +import net.openan.a2at.sdk.core.validation.ContentValidationException; +import net.openan.a2at.sdk.server.A2ATServer; +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_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/v1"; + private static final String NOTIFICATION_T_EXT_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // 1) CDI Producer:向 a2a-java 暴露服务端AgentCard(定义同 1.4.4 前置步骤中的服务端样例 AgentCard) + @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_URI) + .description("Extension of structured prompt TASK-T requests.") + .required(false) + .build(), + AgentExtension.builder() + .uri(NOTIFICATION_T_EXT_URI) + .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 ServiceRecoverySubscriptionExecutor(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任务执行器:接收Notification-T订阅报文并校验提参(实现同 1.4.4 Step6-Step7) + static final class ServiceRecoverySubscriptionExecutor implements AgentExecutor { + + private static final String NOTIFICATION_T_EXT_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Notification-T/v1"; + + private static final String NOTIFICATION_TEMPLATE_URI = + "Notification-T/network-layer/service-recovery/v1"; + + // 参数提取Schema(与 a2a-t-sample/src/main/resources/sample/service-recovery/server/schema.json 一致) + private static final Map NOTIFICATION_PARAM_SCHEMA = Map.of( + "type", "object", + "properties", Map.of( + "topic", Map.of("type", "string", "description", "订阅主题(必选)。订阅的事件主题名称。"), + "subscriptionCondition", Map.of( + "type", "string", "description", "订阅条件(可选)。待订阅的条件描述。"), + "notificationDataFormat", Map.of( + "type", "string", "description", "上报通知数据格式(必选)。待上报的通知数据格式描述。")), + "required", List.of("topic", "notificationDataFormat")); + + private final A2ATServer server; + + ServiceRecoverySubscriptionExecutor(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 notification prompt")); + return; + } + agentEmitter.submit(statusMessage(requestContext, + "Subscription accepted, starting service recovery event reporting task")); + + // 4.2) 使用SDK能力校验Notification-T订阅报文并按Schema提取订阅参数 + Map filledParams; + try { + FilledParamData checkResult = server.validateNotificationPromptAndDataFilling( + processedPrompt, NOTIFICATION_PARAM_SCHEMA, NOTIFICATION_TEMPLATE_URI); + filledParams = checkResult.data(); + } catch (ContentValidationException error) { + // 校验失败,exception 携带 code、message 与 errors() 逐槽位明细 + agentEmitter.reject(statusMessage(requestContext, + "Notification prompt validation failed: " + error.getCode() + ": " + error.getMessage())); + return; + } + + // 4.3) 校验通过,按提取的订阅参数(topic/subscriptionCondition/notificationDataFormat)建立订阅并上报 + // 业务抢通事件(事件字段取自样例 a2a-t-sample/.../service-recovery/server/mock-event-data.json, + // “业务抢通方案详情”字段较长,此处省略) + agentEmitter.startWork(statusMessage(requestContext, + "Subscribed to [" + filledParams.get("topic") + "], service recovery event reporting in progress")); + agentEmitter.addArtifact( + List.of(new DataPart(Map.of( + "serviceManagement.ServiceRecoveryEvent", Map.of( + "业务抢通方案执行状态", "已结束", + "投诉诊断任务流水号", "9de168c0-6179-4778-8b72-4279582c0a3f", + "OSS侧事件流水号", "event-id-202606250128", + "接入端口名称", "P781-SZ-PTN7900-23-TPA1EG24-17", + "是否已授权OMC自动抢通", "是", + "业务抢通方案名称", "隧道调优", + "业务抢通方案执行结束时间", "2026-05-11T08:21:46Z")))), + "serviceManagement.ServiceRecoveryEvent", + "Service recovery event artifact", + null); + agentEmitter.complete(statusMessage(requestContext, + "Service recovery notification reporting finished, subscription task 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_T_EXT_URI); + 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(); + } + } +} ``` -然后在 `.env` 中指定: +在 `src/main/resources/application.properties` 中配置服务监听地址: ```properties -A2AT_PROMPT_SOURCE_TYPE=local_file -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR={your_prompt_resources_root} -A2AT_LANGUAGE=zh-CN +quarkus.http.host=127.0.0.1 +quarkus.http.port=26335 ``` -建议为新增资源补充: - -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 段落 -要创建一个段落,可以使用空行将一行或多行文本分隔开。样例: +```bash +mvn clean package +java -jar target/quarkus-app/quarkus-run.jar ``` -## 任务描述 -处理A社区的5G服务故障 -完成服务恢复 +服务端启动后会: + +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)。 + +## 1.5 加载自定义模板 + +### 1.5.1 背景 + +SDK 在生成和校验 A2A-T 提示词时依赖三类提示词资源:场景目录(scenarios)、槽位定义(slots)和模板正文(templates)。内置资源打包在 `a2a-t-resources` jar 中,默认从 classpath 加载。当业务方需要使用自己的业务场景模板(例如新增业务场景、调整模板措辞或槽位约束)时,可以将资源来源切换为 `local_file`,从本地目录加载自定义模板,无需重新打包 SDK。 + +**自定义模板的能力边界如下**: + +| 资源 | `local_file` 模式 | `classpath` 模式 | +| --- | --- | --- | +| 业务模板/槽位/场景(Task-T、Notification-T、Authorization-T 的 templates/slots/scenarios) | 本地优先,缺失回退内置(overlay) | 内置资源 | +| 协商模板(Negotiation-T 固定集合)与协商词表(negotiation-vocabulary) | 从 classpath 加载,不可自定义 | 内置资源 | +| LLM 指令提示词(prompts 目录) | 从 classpath 加载,不可自定义 | 内置资源 | + +**关键约束**: -识别故障的根本原因并进行修复 +1. **构造时校验**:`A2ATClient` 与 `A2ATServer` 在构造时即校验提示词资源配置。`local_file` 模式下 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 未设置、路径不存在或不是目录时,构造时直接失败并给出明确的错误信息。 +2. **初始化载入**:SDK 在构造时一次性读取本地根目录下 `templates/`、`slots/`、`scenarios/` 的全部内容形成只读快照,运行期不再访问文件系统;修改本地文件后需重启 SDK 进程才能生效。 +3. **本地优先、内置兜底(overlay)**:`local_file` 模式下业务内容(Task-T/Notification-T/Authorization-T 的 `templates`/`slots`/`scenarios`)本地存在则读取本地,本地缺失回退 classpath 内置,本地与内置均缺失才报错。因此客户只需放置需要覆盖或新增的模板,无需整树拷贝内置资源;`scenarios/{语言}/scenarios.json` 也不再必填,本地根目录无场景目录时回退内置场景目录。每个业务资源路径首次发生内置兜底时输出一次 WARN(按资源路径去重),提示实际来源。 + +### 1.5.2 实施步骤 + +#### Step1 准备本地资源根目录 + +参考 `a2a-t-resources/src/main/resources/prompt_resources` 的目录结构,在本地根目录下按需放置资源文件: + +```text +<本地资源根目录>/ +├── templates/ +│ └── /<场景路径>/v1/<语言>/template.md +│ 例如 templates/Task-T/network-layer/ran-energy-saving/v1/zh-CN/template.md +├── slots/ +│ └── /<场景路径>/v1/<语言>/slot.json +└── scenarios/ + └── <语言>/scenarios.json ``` -##### 1.8.2.4.2 有序列表 -要创建有序列表,需添加以数字加句点表示的条目。这些数字不必按顺序排列,但列表应从数字1开始。样例: +说明: + +1. `` 支持 `Task-T`、`Notification-T`、`Authorization-T`;模板版本段固定为 `v1`;内置语言为 `zh-CN` 与 `en-US`。 +2. `<场景路径>` 配置方式为`network-layer/<场景编码>`,例如 `network-layer/ran-energy-saving`; +3. 本地根目录中若存在 `prompts/`、`templates/Negotiation-T/`、`negotiation-vocabulary/` 目录,构造时会被忽略并输出告警日志(这些资源从 classpath 加载)。 + +#### Step2 编写资源文件 + +**模板正文 template.md**:Markdown 文件,使用 `{{槽位名}}` 占位符引用槽位,例如: + +```markdown +## 操作类型(Operation Type) + +{{操作类型}}(必填) ``` -## 预期输出 -1. Bar -2. Foo + +**槽位定义 slot.json**:JSON Schema(draft 2020-12)格式,通过 `properties` 声明各槽位的类型、`description`、`examples`,可用扩展字段 `x-a2at-value-constraint` 补充取值约束(供 LLM 抽取与语义校验使用),例如: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "操作类型": { + "type": "string", + "description": "请提供操作类型,取值范围:创建、修改", + "examples": ["创建"], + "x-a2at-value-constraint": "取值范围:创建、修改" + } + } +} ``` -##### 1.8.2.4.3 无序列表 -要创建无序列表,请在每行项目前添加破折号(-)。缩进一个或多个项目以创建嵌套列表。样例: +**场景目录 scenarios.json**:顶层为 `scenarios` 数组,每项包含 `scenario_code`、`scenario_name`、`description`、`example` 字段,例如: + +```json +{ + "scenarios": [ + { + "scenario_code": "ran-energy-saving", + "scenario_name": "能效优化任务下发", + "description": "用于生成电信网管系统中的能效优化任务的 A2A-T 任务请求。", + "example": "实现松山湖园区 30%的能耗降低,同时确保速率保障不低于 10Mbps。" + } + ] +} ``` -## 预期输出 -- 项目 - - 项目1 -- Bar2 -- Foo + +#### Step3 配置资源来源 + +修改 `client.env`(服务端对应 `server.env`),将资源来源切换为本地文件并指定根目录: + +```properties +A2AT_LANGUAGE=zh-CN +A2AT_PROMPT_SOURCE_TYPE=local_file +A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=/opt/a2at/prompt_resources ``` -##### 1.8.2.4.4 链接 -要创建一个链接,请在方括号内输入链接文本,然后在其后立即加上用圆括号括起来的网址。样例: + +说明: + +1. `A2AT_PROMPT_SOURCE_TYPE` 取值为 `classpath`(默认)或 `local_file`,其他取值在构造时报 `Unsupported prompt source type`。 +2. `local_file` 模式下 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` 必填:未设置时报错并提示设置 `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR`;路径不存在或不是目录时同样装配失败。 +3. 本地根目录的相对路径按 `.env` 文件所在目录解析,建议使用绝对路径。 +4. `classpath` 模式下即使配置了本地根目录也会被忽略:生成/校验路径会输出告警日志,模板查询路径则静默忽略。 + +**templateUri 回填** + +`generateTaskPromptFromText` 接口跳过场景识别,模板由调用方通过 `templateUri` 显式指定。`templateUri` 与本地目录一一对应,回填规则如下: + +```text +本地文件:<本地资源根目录>/templates////<语言>/template.md +templateUri:// ``` -## 任务描述 -处理[TM Forum AN](自治网络项目主页)无法加载的问题。 + +例如本地文件为 `<本地资源根目录>/templates/Task-T/network-layer/site-inspection/v1/zh-CN/template.md` 时,对应的 `templateUri` 为 `Task-T/network-layer/site-inspection/v1`。 + +```java +import net.openan.a2at.sdk.core.model.MetadataContent; + +// 直接接收原始 URI 字符串 +MetadataContent metadata = client.generateTaskPromptFromText("请对xx基站开展现场巡检,重点检查告警和性能指标", "Task-T/network-layer/site-inspection/v1"); ``` +对 `templateUri` 的失败策略:为 null 抛 `NullPointerException`;为空白或非法 URI(少于三段,或某一段不是简单段)抛 `IllegalArgumentException`,消息为 `Unparseable template URI: <输入>`。 -#### 1.8.2.5 模板定义的步骤 -建议按照如下步骤定义prompt模板: -1. 确定任务类型:明确当前业务场景的任务类型和协作模式,从A2A-T任务分类体系中定位对应的模板类别(如 Task-T); -2. 编写必要指令:选取关键指令,使用结构化的格式声明任务目标、执行条件、输入参数、预期输出等信息; -3. 填充常用变量:声明本次任务实例所涉及的具体参数,变量引用需要遵循A2A-T变量语法规范; -4. 绑定上下文信息:补充智能体完成任务所需的上下文信息; -5. 设置输出定义:明确定义输出格式、验收标准和异常处理规则; -6. 验证模板完整性:在实际跨模型环境中进行充分测试,验证语法规范性和跨LLM兼容性; -7. 版本迭代优化:将经过验证的模板纳入版本管理体系,便于持续的治理、迭代和演进。 +如需覆盖内置模板(如 `StandardTemplates.PRIVATE_LINE_COMPLAINT_URI`),在本地根目录下按相同相对路径放置 `template.md` 与 `slot.json`,代码中继续使用原有常量即可。`local_file` 模式下业务模板本地优先、缺失回退内置:本地放置了对应文件则读取本地版本,未放置则回退 classpath 内置版本;本地与内置均缺失时接口抛出 `PromptGenerationException`,错误码为 `template.not_found`。 -## 1.9 测试建议 +#### Step4 验证与排障 -常用测试命令: +启动客户端或服务端后,可按以下方式确认与排障: -```bash -mvn test -``` +1. **资源缺失**:模板未找到时,生成链路抛出 `PromptGenerationException`、校验链路抛出 `ContentValidationException`,错误码均为 `template.not_found`,异常提示中包含期望的文件路径,按提示补齐目录结构即可。 +2. **内容未更新**:修改本地文件后不重启进程不生效,重启 SDK 进程后重新构造。 +3. **协商与 LLM 资源不可定制**:`Negotiation-T` 模板只支持内置的固定集合,集合之外的协商模板 URI 会被跳过并输出告警。 -修改客户端提示词生成时: +## 1.6 日志打印配置说明与集成指引 -```bash -mvn -pl a2a-t-client -am test +### 1.6.1 日志机制概述 + +SDK 的 LLM 调用日志通过 SLF4J 输出,使用**专用 logger** `net.openan.a2at.sdk.llm.call`,与应用其他业务日志解耦,便于单独控制级别与过滤: + +| 日志类别 | 级别 | 内容 | 是否受开关控制 | +| --- | --- | --- | --- | +| 调用摘要日志(`llm_call event=request` / `event=response` / `event=error`) | DEBUG | 时间戳、模型、消息数/字符数、耗时(elapsed_ms)、输入/输出/总 Token(prompt_tokens/completion_tokens/total_tokens)、响应内容长度(content_chars)、response_id;**不含报文内容** | 否,随 DEBUG 级别输出 | +| 完整报文日志(`llm_call event=request_body` / `event=response_body`) | DEBUG | 完整请求消息与响应内容,**不做截断** | 是,由 `A2AT_LLM_DETAIL_LOG_ENABLED` 控制,默认关闭 | + +摘要日志示例: + +```text +llm_call event=response ts=2026-09-14T08:12:36.012Z provider=openai model=deepseek-v3 elapsed_ms=2556.4 prompt_tokens=512 completion_tokens=120 total_tokens=632 content_chars=344 response_id=chatcmpl-abc123 ``` -修改服务端校验时: +要点: -```bash -mvn -pl a2a-t-server -am test +1. **默认不打印任何报文**:`A2AT_LLM_DETAIL_LOG_ENABLED` 默认 `false`;摘要日志仅在集成方把专用 logger 打开到 DEBUG 时可见,生产默认 INFO/WARN 级别下 SDK 无任何新增输出。 +2. **两级控制**:先通过应用日志框架把 `net.openan.a2at.sdk.llm.call` 级别调为 DEBUG(决定摘要日志是否可见),再按需设置 `A2AT_LLM_DETAIL_LOG_ENABLED=true`(决定是否打印完整报文)。 +3. **风险提示**:开启开关会打印 LLM 交互详细内容,可能打印敏感信息(业务模板、槽位、协商消息、模型返回等)或占用大量日志空间,**仅限项目 DEBUG 阶段使用,正式运行时必须关闭该配置项**。 + +### 1.6.2 配置说明 + +在 `client.env` / `server.env` 中添加: + +```properties +# 是否打印 LLM 请求与响应的完整报文(不截断)。默认 false。 +# 风险提示:开启会打印 LLM 交互详细内容,可能打印敏感信息或占用日志空间, +# 仅限项目 DEBUG 阶段使用,正式运行时需关闭此配置项。 +A2AT_LLM_DETAIL_LOG_ENABLED=false ``` -修改协商状态机时: +| 取值 | 行为 | +| --- | --- | +| `false`(默认、未配置或空值) | 不打印请求/响应的完整报文 | +| `true` | 在 DEBUG 级别日志的基础上追加打印完整报文,不做截断 | -```bash -mvn -pl a2a-t-negotiation -am test +`true`/`false` 大小写不敏感;其他非法取值记入配置解析错误,SDK 构造时抛出 `LLMConfigError`。 + +### 1.6.3 集成方式配置指引 + +打开 LLM 调用日志需要应用侧配合(建议仅在联调环境执行)。日志级别(logger)与输出位置/绕接(appender)是日志配置的两个层次:级别决定哪些日志事件产生,appender 决定事件写到哪个文件、如何滚动。以下为常见框架的完整配置示例(级别 + 输出路径 + 绕接配置): + +**logback(logback.xml / logback-spring.xml)** + +```xml + + + + + logs/llm-call.log + + + logs/llm-call.%d{yyyy-MM-dd}.%i.log.gz + 100MB + 7 + 2GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level %msg%n + + + + + + + + + + + + + ``` -修改 sample 时: +**Spring Boot(application.yml,Spring Boot 2.4+)** + +```yaml +logging: + level: + net.openan.a2at.sdk.llm.call: DEBUG + # 输出路径与绕接配置(作用于 Spring Boot 默认文件 appender) + file: + name: logs/llm-call.log + logback: + rollingpolicy: + max-file-size: 100MB + max-history: 7 + total-size-cap: 2GB +``` -```bash -mvn -pl a2a-t-sample -am -DskipTests package +说明:该配置会把应用的**全部日志**(含 LLM 调用日志)写入 `logs/llm-call.log` 并按上述策略绕接;`application.yml` 无法按 logger 单独指定文件。如需 LLM 调用日志**单独成文件**,请添加 `logback-spring.xml`(Spring Boot 默认集成 logback)并套用上方 logback 示例。 + +**Quarkus(application.properties)** + +```properties +# 级别 +quarkus.log.category."net.openan.a2at.sdk.llm.call".level=DEBUG +# 仅写入专用文件 handler,不再冒泡到控制台 +quarkus.log.category."net.openan.a2at.sdk.llm.call".use-parent-handlers=false +# 专用文件 handler:输出路径 +quarkus.log.handler.file.llm-call.enable=true +quarkus.log.handler.file.llm-call.path=logs/llm-call.log +# 绕接配置:按天滚动,保留最近 7 份 +quarkus.log.handler.file.llm-call.rotation.file-suffix=.yyyy-MM-dd +quarkus.log.handler.file.llm-call.rotation.max-backup-index=7 +# 该 logger 绑定到专用文件 handler +quarkus.log.category."net.openan.a2at.sdk.llm.call".handlers=llm-call ``` +> 说明:SDK 只产生日志事件,不配置输出位置;日志打印路径与绕接策略全部由集成方 appender 决定(上文示例仅作建议)。开启 `A2AT_LLM_DETAIL_LOG_ENABLED` 后每次 LLM 调用会输出完整报文,建议按示例设置单文件大小上限与保留天数,避免占满磁盘。若应用未提供 SLF4J 绑定(如 logback/log4j2),日志会被丢弃并仅打印一条 "No SLF4J providers were found" 告警。 + +## 1.7 配置项列表 + +SDK提供的样例配置文件为`env.example`,A2A-T Client和A2A-T Server构造时均需拷贝文件至对应的`client.env`、`server.env`,配置项含义如下: + +| 配置项 | 说明 | +| ------------------------------------- | ------------------------------------------------------------ | +| `A2AT_LANGUAGE` | 提示词资源语言,内置 `zh-CN` 与 `en-US`,默认 `en-US` | +| `A2AT_PROMPT_SOURCE_TYPE` | 提示词资源来源,支持 `classpath` 和 `local_file`,默认 `classpath` | +| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | 本地提示词资源根目录;`local_file` 模式下必填,未设置或路径不存在时装配期快速失败;业务内容(Task-T/Notification-T/Authorization-T 的 templates/slots/scenarios)从该根本地优先加载、缺失回退内置(`scenarios.json` 本地可省略),协商资源与 LLM prompt 恒从 classpath 加载 | +| `A2AT_INPUT_TEXT_MAX_CHARS` | 自由文本输入(FromText 生成与报文校验入口)的最大字符数,超限快速失败并报错误码 `input.text_too_long`,默认 `16384`;不涉及 LLM 调用的结构化数据不受此限制 | +| `A2AT_LLM_PROVIDER` | LLM 协议支持类型,当前仅支持 `openai` | +| `A2AT_LLM_MODEL` | 模型名称 | +| `A2AT_LLM_API_KEY` | LLM API Key | +| `A2AT_LLM_BASE_URL` | LLM 服务地址(OpenAI-compatible) | +| `A2AT_LLM_MAX_TOKENS` | 补全调用的最大生成 token 数,示例值 `2000`,留空时使用提供方默认 | +| `A2AT_LLM_TEMPERATURE` | 采样温度,示例值 `0`,留空时使用提供方默认 | +| `A2AT_LLM_TIMEOUT_SECONDS` | LLM 请求超时时间(秒),示例值 `60`,留空时使用提供方默认 | +| `A2AT_LLM_HISTORY_WINDOW` | 保留的会话历史消息条数,默认 `10` | +| `A2AT_LLM_REASONING_EFFORT` | 推理力度,取值 `none`/`minimal`/`low`/`medium`/`high`/`xhigh`,留空时不设置 | +| `A2AT_LLM_DISABLE_SYSTEM_PROXY` | 是否绕过系统 HTTP 代理访问 LLM,默认 `false` | +| `A2AT_LLM_SSL_VERIFY` | 是否校验 LLM 接入点的 TLS 证书链与主机名,置 `false` 同时关闭两者;优先导入受信任 CA,仅受控环境短期使用,默认 `true` | +| `A2AT_LLM_SESSION_MAX_TOTAL` | 会话跟踪总数上限,默认 `300` | +| `A2AT_LLM_SESSION_MAX_PER_PROVIDER` | 单提供方会话跟踪数上限,默认 `100` | +| `A2AT_LLM_MAX_ATTEMPTS` | 可重试 LLM 步骤的最大尝试次数,取值范围 1-10(越界自动裁剪),默认 `3` | +| `A2AT_LLM_DETAIL_LOG_ENABLED` | 是否打印 LLM 请求/响应的完整报文(不截断),默认 `false`;开启后可能打印敏感信息或占用日志空间,仅限 DEBUG 阶段使用、正式运行必须关闭;时间/Token/耗时等摘要日志由专用 logger `net.openan.a2at.sdk.llm.call` 以 DEBUG 级输出,详见 1.6 | +| `A2AT_NEGOTIATION_STATE_STORE_TYPE` | 协商状态存储,当前支持 `in_memory` | diff --git "a/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" "b/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" deleted file mode 100644 index f09ca1d3..00000000 --- "a/docs/zh/\347\224\250\346\210\267\346\214\207\345\215\227.md" +++ /dev/null @@ -1,172 +0,0 @@ -# 1 a2a-t-sdk-java 用户指南 - -## 1.1 特性介绍 -### 1.1.1 什么是A2A-T? -A2A-T(Agent-to-Agent Telecom)是基于A2A协议推出的电信领域多智能体互联协议,专为电信领域复杂协同场景设计。 -业界通用智能体互联协议主要关注智能体互联和交互框架,对业务场景和具体交互内容关注不足,导致任务完成的成功率不高。电信领域的业务场景复杂、要求高,运维智能体互联和协同需要有专门的协议进行支撑。A2A-T方案基于A2A协议,重点针对电信领域业务流相关的信息模型、任务协商、协作安全等增强能力进行应用扩展。 - -### 1.1.2 A2A-T SDK与A2A SDK的关系 - -A2A-T协议是基于A2A协议的扩展,针对协议扩展的内容提供了A2A-T SDK,支撑快速构建满用于电信领域复杂协同场景的智能体。A2A-T SDK独立于A2A SDK,通过同时集成A2A-T SDK与A2A SDK可以构建支持A2A-T协议的agent,支持电信领域多agent之间的确定性、高可靠、高效及安全协同。 - -### 1.1.3 能力介绍 - -a2a-t-sdk-java 是 A2A-T 协议的 Java SDK 实现,提供客户端提示词生成、服务端提示词校验和协商运行时能力。 - -主要能力包括: - -- **客户端提示词生成**:通过 `A2ATClient` 将自然语言或结构化输入转换为A2A-T协议报文。 -- **服务端提示词校验**:通过 `A2ATServer` 校验A2A-T协议报文 的场景、槽位和语义一致性。 -- **协商流程**:支持 `information`协商。 -- **模块化 Maven 工程**:按 core、resources、llm、prompt、negotiation、client、server、sample 分层组织。 -- **A2A Java 集成样例**:`a2a-t-sample` 基于 `a2a-java v1.0.0.Beta1` 演示真实 HTTP+JSON/REST 链路。 - -## 1.2 应用场景 - -Java SDK 通常用于以下场景: - -1. Java 客户端 Agent 接收用户意图,生成 A2A-T 协议报文。 -2. Java 服务端 Agent 接收 A2A 请求,校验 prompt 后进入业务执行。 -3. 客户端和服务端通过协商上下文进行多轮信息补充或任务可行性确认。 -4. 与注册中心配合,实现 AgentCard 注册、查询和 A2A 调用地址解析。 - -## 1.3 环境要求 - -| 项目 | 要求 | -| --- | --- | -| JDK | 17+ | -| 构建工具 | Maven 3.8+ | -| LLM | 可访问的 OpenAI-compatible 服务及 API Key | -| 注册中心 | 运行 Java sample 时需要先启动 registry-center | -| A2A Java | sample 基于 `a2a-java v1.0.0.Beta1` | - -## 1.4 编译安装 - -在仓库根目录执行: - -```bash -cd {项目路径}/a2a-t-sdk-java -mvn -DskipTests package -``` - -如需使用本地 Maven 仓库目录: - -```bash -mvn "-Dmaven.repo.local=.mvn/repository" -DskipTests package -``` - -运行测试: - -```bash -mvn test -``` - -## 1.5 准备配置 - -仓库根目录提供 `client.env` 和 `server.env`,可直接按本地环境修改。 - -客户端配置示例: - -```properties -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=classpath -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY={your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -REGISTRY_CENTER_HOST=127.0.0.1 -REGISTRY_CENTER_PORT=5001 -``` - -服务端配置示例: - -```properties -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=classpath -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= -A2AT_LLM_PROVIDER=openai_compatible -A2AT_LLM_MODEL=deepseek-chat -A2AT_LLM_BASE_URL=https://api.deepseek.com -A2AT_LLM_API_KEY={your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -A2AT_SAMPLE_HOST=127.0.0.1 -A2AT_SAMPLE_PORT=26335 -REGISTRY_CENTER_HOST=127.0.0.1 -REGISTRY_CENTER_PORT=5001 -``` - -## 1.6 快速运行示例 - -Java 示例模块位于 `a2a-t-sdk-java/a2a-t-sample`。 - -### 1.6.1 前置条件 - -1. 启动注册中心服务。 -2. 确认 `server.env` 中的 `REGISTRY_CENTER_HOST` 和 `REGISTRY_CENTER_PORT` 指向注册中心。 -3. 确认 `client.env` 中的注册中心地址与服务端一致。 -4. 填写 `client.env` 和 `server.env` 中的 `A2AT_LLM_API_KEY`。 - -### 1.6.2 编译 sample - -```bash -cd {项目路径}/a2a-t-sdk-java -mvn "-Dmaven.repo.local=.mvn/repository" -pl a2a-t-sample -am -DskipTests package -``` - -### 1.6.3 启动服务端 - -```bash -java @a2a-t-sample/target/server.javaargs.txt -``` - -服务端启动后会: - -- 构造示例 AgentCard。 -- 向注册中心注册 AgentCard。 -- 启动本地 A2A HTTP 服务。 -- 等待客户端请求并校验 A2A-T prompt。 - -### 1.6.4 启动客户端 - -另开终端执行: - -```bash -java @a2a-t-sample/target/client.javaargs.txt -``` - -客户端会: - -1. 从 `sample/client/scenario.json` 读取示例输入。 -2. 根据配置查询注册中心中的目标 AgentCard。 -3. 使用 `A2ATClient` 生成 processed task prompt。 -4. 构造 A2A HTTP+JSON/REST 请求。 -5. 接收服务端任务状态、消息和 artifact 事件。 - - -## 1.7 配置文件速查 - -| 配置项 | 说明 | -| --- | --- | -| `A2AT_LANGUAGE` | 提示词资源语言,常用 `zh-CN` | -| `A2AT_PROMPT_SOURCE_TYPE` | 提示词资源来源,支持 `classpath` 和 `local_file`,默认 `classpath` | -| `A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR` | 本地提示词资源根目录 | -| `A2AT_LLM_PROVIDER` | LLM 提供方,sample 使用 `openai_compatible` | -| `A2AT_LLM_MODEL` | 模型名称 | -| `A2AT_LLM_BASE_URL` | LLM 服务地址 | -| `A2AT_LLM_API_KEY` | LLM API Key | -| `A2AT_NEGOTIATION_STATE_STORE_TYPE` | 协商状态存储,当前支持 `in_memory` | -| `A2AT_SAMPLE_HOST` | sample 服务端监听地址 | -| `A2AT_SAMPLE_PORT` | sample 服务端监听端口 | -| `REGISTRY_CENTER_HOST` | 注册中心地址 | -| `REGISTRY_CENTER_PORT` | 注册中心端口 | - -## 1.8 约束与限制 - -1. 当前版本为 Alpha,接口和模块组织可能随版本演进调整。 -2. 协商状态当前使用内存存储,不提供跨进程持久化。 -3. `A2ATClient` 和 `A2ATServer` 不自动查找 `.env`,调用方必须显式传入配置文件路径。 -4. SDK 不提供认证鉴权、密钥管理和 AgentCard 注册中心服务能力。 -5. sample 依赖注册中心完成 AgentCard 注册和查询,单独运行客户端前需要先启动服务端并完成注册。 - diff --git a/env.example b/env.example index 038cedbc..398ef01e 100644 --- a/env.example +++ b/env.example @@ -1,15 +1,26 @@ # Prompt runtime # Controls the language used by prompt runtime components. A2AT_LANGUAGE=en-US -# Selects the prompt source backend. -A2AT_PROMPT_SOURCE_TYPE=local_file -# Optional when A2AT_PROMPT_SOURCE_TYPE=local_file. +# Selects the prompt source backend (classpath | local_file). +A2AT_PROMPT_SOURCE_TYPE=classpath +# Required when A2AT_PROMPT_SOURCE_TYPE=local_file (assembly-time fail-fast if +# unset or the path does not exist). Business content (templates/slots/scenarios +# of Task-T/Notification-T/Authorization-T) is loaded from this root with a +# local-first overlay: a missing resource falls back to the built-in classpath +# copy (each resource path warns once about the fallback). Negotiation resources +# and LLM prompts are always loaded from the classpath. A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= # Prompt compliance # 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=16384 + # LLM runtime # Selects the LLM provider used by the shared client. A2AT_LLM_PROVIDER= @@ -27,11 +38,45 @@ 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 +# Whether to verify the TLS certificate chain and hostname of the LLM endpoint. Set to false +# only short-term in controlled environments when the HTTPS LLM gateway presents a certificate +# that is not trusted; prefer importing a trusted CA instead. +A2AT_LLM_SSL_VERIFY=true # Maximum total number of tracked sessions. A2AT_LLM_SESSION_MAX_TOTAL=300 # Maximum number of tracked sessions per provider. A2AT_LLM_SESSION_MAX_PER_PROVIDER=100 +# Maximum number of attempts for retryable LLM steps (range 1-10, default 3). +A2AT_LLM_MAX_ATTEMPTS=3 +# Whether to print the full LLM request and response payloads (no truncation). Defaults to +# false. WARNING: enabling this prints detailed LLM interaction content, which may expose +# sensitive information and consume large amounts of log space; use ONLY during project +# debugging and keep it disabled in production. +# Summary logs (timestamp, token usage, elapsed time) are recorded at DEBUG level whenever +# the dedicated logger net.openan.a2at.sdk.llm.call is enabled for DEBUG; where the lines +# are printed is decided by the embedding application's logging framework. +A2AT_LLM_DETAIL_LOG_ENABLED=false # Negotiation # Selects the negotiation state store implementation. A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + +# --- Observability (a2a-t-observability) --- +OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED=true +A2AT_OBSERVABILITY_ENABLED=true +A2AT_TRACE_ENABLED=true +A2AT_METRIC_ENABLED=true +A2AT_LOG_ENABLED=true +#OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +A2AT_EXPORTER_PROTOCOL=grpc +A2AT_CONSOLE_EXPORTER=true +#OTEL_SERVICE_NAME=a2a-t-agent +#A2AT_AGENT_ROLE= +A2AT_EXTRACT_REQUEST=false +A2AT_EXTRACT_RESPONSE=false +A2AT_LOGS_PAYLOAD_ENABLED=false +A2AT_LOGS_PAYLOAD_MAX_LENGTH=4096 diff --git a/pom.xml b/pom.xml index f9847d5f..2659005b 100644 --- a/pom.xml +++ b/pom.xml @@ -21,11 +21,14 @@ a2a-t-negotiation a2a-t-client a2a-t-server + a2a-t-observability + a2a-t-observability-a2a + a2a-t-corpus a2a-t-sample
      - 0.9.0 + 1.1.1 17 UTF-8 3.3.1 @@ -33,16 +36,24 @@ 3.13.0 3.2.5 3.2.4 - 0.9.0 + 1.6.0 + 0.11.0 2.44.5 2.55.0 5.10.2 + 1.8.5 1.18.38 2.20.1 2.20 3.14.0 4.36.0 2.0.17 + 1.64.0 + 1.1.0.Final + 2.50.0 + 0.13.4 + -XDaddTypeAnnotationsToSymbol=true + -Xplugin:ErrorProne -XepDisableAllChecks -Xep:NullAway:ERROR -Xep:RequireExplicitNullMarking:WARN -XepOpt:NullAway:ExhaustiveOverride=true -XepOpt:NullAway:OnlyNullMarked=true -XepOpt:NullAway:JSpecifyMode=true -XepExcludedPaths:.*/src/test/.* -XepDisableWarningsInGeneratedCode @@ -89,9 +100,42 @@ slf4j-api ${slf4j.version} + + org.jspecify + jspecify + 1.0.1 + provided + + + io.opentelemetry + opentelemetry-bom + ${opentelemetry.version} + pom + import +
      + + + maven-central + Maven Central + https://repo.maven.apache.org/maven2/ + true + false + + + + + + maven-central-plugins + Maven Central Plugins + https://repo.maven.apache.org/maven2/ + true + false + + + org.projectlombok @@ -102,6 +146,10 @@ com.fasterxml.jackson.core jackson-databind + + org.jspecify + jspecify + @@ -116,6 +164,36 @@ OpenAN https://openan.net + + + Yu Gong + gongyu23@huawei.com + + + Jiansheng Jiang + jiangjiansheng1@huawei.com + + + Shunhao Wu + wushunhao1@huawei.com + + + Lei Yin + yinlei13@huawei.com + + + Jiming Wang + wangjiming12@huawei.com + + + Jie Zou + zoujie5@huawei.com + + + Xuancheng Zhou + zhouxuancheng@chinamobile.com + + https://github.com/project-openan/a2a-t-sdk-java @@ -138,15 +216,29 @@ maven-javadoc-plugin - org.sonatype.central - central-publishing-maven-plugin - ${central.publishing.plugin.version} - true + org.codehaus.mojo + flatten-maven-plugin + ${flatten.maven.plugin.version} - central - true - published + resolveCiFriendliesOnly + true + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + @@ -170,13 +262,56 @@ maven-compiler-plugin ${maven.compiler.plugin.version} - ${maven.compiler.release} + 17 + 17 + 17 + UTF-8 + true + + -J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED + -J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED + -J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED + -XDcompilePolicy=simple + --should-stop=ifError=FLOW + -parameters + ${error-prone.flag} + ${nullaway.args} + + + + org.projectlombok + lombok + ${lombok.version} + + + com.google.errorprone + error_prone_core + ${error-prone.version} + + + com.uber.nullaway + nullaway + ${nullaway.version} + + org.apache.maven.plugins maven-surefire-plugin ${maven.surefire.plugin.version} + + + false + org.apache.maven.plugins @@ -218,14 +353,23 @@ release + + org.sonatype.central + central-publishing-maven-plugin + ${central.publishing.plugin.version} + true + + central + true + validated + + org.apache.maven.plugins maven-gpg-plugin ${maven.gpg.plugin.version} ${gpg.executable} - ${gpg.keyname} - gpg.passphrase --pinentry-mode loopback @@ -244,5 +388,21 @@ + + + old_jdk_support + + [17, 21] + + + 2.42.0 + 0.12.10 + + -Xplugin:ErrorProne -XepDisableAllChecks -Xep:NullAway:ERROR -XepOpt:NullAway:OnlyNullMarked=true -XepOpt:NullAway:JSpecifyMode=true -XepExcludedPaths:.*/src/test/.* -XepDisableWarningsInGeneratedCode + + - + \ No newline at end of file diff --git a/server.env b/server.env deleted file mode 100644 index f31e2773..00000000 --- a/server.env +++ /dev/null @@ -1,12 +0,0 @@ -A2AT_LANGUAGE=zh-CN -A2AT_PROMPT_SOURCE_TYPE=local_file -A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR=a2a-t-resources/src/main/resources/prompt_resources -A2AT_LLM_PROVIDER=openai_compatible -A2AT_LLM_MODEL= -A2AT_LLM_BASE_URL= -A2AT_LLM_API_KEY=${your_api_key} -A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory -A2AT_SAMPLE_HOST= -A2AT_SAMPLE_PORT= -REGISTRY_CENTER_HOST= -REGISTRY_CENTER_PORT= diff --git a/tools/template_lint.py b/tools/template_lint.py new file mode 100644 index 00000000..7553031c --- /dev/null +++ b/tools/template_lint.py @@ -0,0 +1,582 @@ +#!/usr/bin/env python3 +"""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 + +import argparse +import json +import re +import sys +from pathlib import Path + + +HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$") +SLOT = re.compile(r"{{\s*([^{}\s]+)\s*}}") +TASK = {"Task Description", "Task Type", "Task Target", "Task Object", "Task Context", "Constraints", "Expected Output", "Operation Type", "Terminology Explanation"} +NOTIFICATION = {"Subscription Description", "Notification Topic", "Subscribe Condition", "Notification Data Format", "Expected Output"} +AUTHORIZATION = {"Authorization Policy Operation Type", "Authorization Policy Operation Description", "Network Operation Authorization Policy List", "Expected Output", "Terminology Explanation"} +ALIASES = { + "任务描述": "Task Description", "任务类型": "Task Type", "任务目标": "Task Target", "任务对象": "Task Object", + "目标对象": "Task Object", "任务上下文": "Task Context", "约束条件": "Constraints", "预期输出": "Expected Output", + "操作类型": "Operation Type", + "术语解释": "Terminology Explanation", + "订阅描述": "Subscription Description", "通知主题": "Notification Topic", "订阅条件": "Subscribe Condition", + "通知数据格式": "Notification Data Format", "上报通知数据格式": "Notification Data Format", + "授权策略的操作类型": "Authorization Policy Operation Type", + "授权策略的操作描述": "Authorization Policy Operation Description", + "动网操作的授权策略列表": "Network Operation Authorization Policy List", +} + +NEGOTIATION_TYPE_SEGMENTS = ("information-negotiation", "target-negotiation", "feasibility-negotiation") +NEGOTIATION_PHASE_SEGMENTS = ("propose", "accept-reject") +NEGOTIATION_LANGUAGES = ("zh-CN", "en-US") +NEGOTIATION_STATIC_SECTIONS = {"info_static"} +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_confirm_request"), + ("target-negotiation", "accept-reject"): ("target_conclusion", "target_result_content"), + ("feasibility-negotiation", "propose"): ("feasibility", "feasibility_evaluate", "feasibility_infeasible", "feasibility_confirm_request"), + ("feasibility-negotiation", "accept-reject"): ("feasibility_conclusion", "feasibility_confirm"), +} +NEGOTIATION_MARKER = re.compile( + 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}" + + +def canonical_heading(value: str) -> str: + value = value.strip() + if value in TASK | NOTIFICATION: + return value + match = re.fullmatch(r"(.+?)\s*\(([^)]+)\)", value) + for candidate in (value,) if match is None else (match.group(1).strip(), match.group(2).strip()): + if candidate in ALIASES: + return ALIASES[candidate] + if candidate in TASK | NOTIFICATION: + return candidate + return value + + +def schema_slots(path: Path, errors: list[str]) -> set[str]: + try: + schema = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + errors.append(error(path, 1, "schema-json", f"Cannot load JSON Schema: {exc}")) + return set() + properties = schema.get("properties") + if not isinstance(properties, dict): + errors.append(error(path, 1, "schema-properties", "Schema must define an object 'properties'.")) + return set() + names = set(properties) + required = schema.get("required", []) + if not isinstance(required, list) or not all(isinstance(name, str) for name in required): + errors.append(error(path, 1, "schema-required", "Schema 'required' must be an array of strings.")) + else: + for name in required: + if name not in names: + errors.append(error(path, 1, "schema-required", f"Required slot '{name}' is not in properties.")) + return names + + +def lint_pair(template_path: Path, schema_path: Path) -> list[str]: + errors: list[str] = [] + slots = schema_slots(schema_path, errors) + try: + lines = template_path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + return [*errors, error(template_path, 1, "template-read", f"Cannot read template: {exc}")] + headings: list[tuple[int, str]] = [] + placeholders: list[tuple[int, str]] = [] + for line_no, line in enumerate(lines, 1): + match = HEADING.match(line) + if match: + if len(match.group(1)) == 2: + headings.append((line_no, canonical_heading(match.group(2)))) + placeholders.extend((line_no, name) for name in SLOT.findall(line)) + names = [name for _, name in headings] + if "Authorization Policy Operation Type" in names: + profile = "authorization" + allowed = AUTHORIZATION + required = {"Authorization Policy Operation Type"} + elif "Subscription Description" in names: + profile = "notification" + allowed = NOTIFICATION + required = {"Subscription Description"} + else: + profile = "task" + allowed = TASK + required = {"Task Description"} + if not headings: + errors.append(error(template_path, 1, "instruction-missing", "Template must contain L0 instructions marked with '##'.")) + for line_no, name in headings: + if name not in allowed: + errors.append(error(template_path, line_no, "instruction-name", f"'{name}' is not valid for the {profile} profile.")) + for name in sorted(required - set(names)): + errors.append(error(template_path, 1, "instruction-required", f"Missing required instruction '{name}'.")) + for name in set(names): + occurrences = [line_no for line_no, value in headings if value == name] + if len(occurrences) > 1: + errors.append(error(template_path, occurrences[1], "instruction-duplicate", f"Instruction '{name}' is repeated.")) + used: set[str] = set() + for line_no, name in placeholders: + if name in used: + continue + used.add(name) + if name not in slots: + errors.append(error(template_path, line_no, "slot-undefined", f"Placeholder '{{{{{name}}}}}' is missing from {schema_path.name}.")) + for name in sorted(slots - used): + errors.append(error(schema_path, 1, "slot-unused", f"Schema slot '{name}' has no template placeholder.")) + return errors + + +def _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 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(): + 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"), 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"]) + 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: + return "要求:" if language == "zh-CN" else "Requirement:" + + +def parse_negotiation_sections(lines: list[str]) -> list[dict]: + sections: list[dict] = [] + current: dict | None = None + for line_no, line in enumerate(lines, 1): + heading = re.match(r"^## (.+?)\s*$", line) + if heading: + current = {"title": heading.group(1), "line": line_no, "body": []} + sections.append(current) + elif current is not None: + current["body"].append((line_no, line)) + return sections + + +def lint_negotiation_file( + path: Path, + language: str, + type_segment: str, + phase_segment: str, + 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: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + return [error(path, 1, "negotiation-template-read", f"Cannot read template: {exc}")], None + profile = NEGOTIATION_PROFILES[(type_segment, phase_segment)] + requirements_label = negotiation_requirements_label(language) + first_line = next((line for line in lines if line.strip()), "") + if first_line.startswith(""): + errors.append(error(path, 1, "negotiation-comment", "HTML comment header must be well-formed ''.")) + sections = parse_negotiation_sections(lines) + if not sections: + errors.append(error(path, 1, "negotiation-section-missing", "Template must contain sections marked with '## '.")) + return errors, None + shape: list[tuple[str, bool | None, bool, int]] = [] + seen: set[str] = set() + for section in sections: + title, line_no, body = section["title"], section["line"], section["body"] + 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 + key, title_language = lookup + if key not in profile: + errors.append( + error(path, line_no, "negotiation-section-name", f"'{title}' is not valid for the {type_segment}/{phase_segment} Negotiation-T template.") + ) + continue + if title_language != language: + errors.append(error(path, line_no, "negotiation-section-name", f"Section title '{title}' is not the {language} title of section '{key}'.")) + continue + if key in seen: + errors.append(error(path, line_no, "negotiation-section-duplicate", f"Section '{title}' is repeated.")) + continue + seen.add(key) + if key in NEGOTIATION_STATIC_SECTIONS: + if any(NEGOTIATION_MARKER.match(text) for _, text in body): + errors.append(error(path, line_no, "negotiation-slot-structure", f"Static section '{title}' must not contain a slot line.")) + if any(text.rstrip() == requirements_label for _, text in body): + errors.append(error(path, line_no, "negotiation-requirements", f"Static section '{title}' must not contain a '{requirements_label}' line.")) + shape.append((key, None, True, line_no)) + continue + marker_line = next(((no, text) for no, text in body if text.strip()), None) + marker = NEGOTIATION_MARKER.match(marker_line[1]) if marker_line else None + if marker is None: + errors.append( + error(path, line_no, "negotiation-slot-structure", f"Slot section '{title}' must be followed by a standalone slot line such as '{{{{slot}}}}(必填)'.") + ) + shape.append((key, None, True, line_no)) + continue + marker_line_no = marker_line[0] + marker_language = "zh-CN" if marker.group("zh") is not None else "en-US" + required = marker.group("zh_kind") == "必填" if marker_language == "zh-CN" else marker.group("en_kind") == "required" + if marker_language != language: + 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(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): + errors.append(error(path, line_no, "negotiation-requirements", f"Slot section '{title}' must contain a '{requirements_label}' line.")) + 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(vocabulary, key)}'.")) + return errors, shape + + +def lint_negotiation_alignment( + zh_path: Path, en_path: Path, zh_shape: list[tuple], en_shape: list[tuple] +) -> list[str]: + errors: list[str] = [] + if len(zh_shape) != len(en_shape): + errors.append(error(en_path, 1, "negotiation-alignment", f"Section count differs from zh-CN counterpart: {len(en_shape)} vs {len(zh_shape)}.")) + for index, (zh_entry, en_entry) in enumerate(zip(zh_shape, en_shape)): + zh_key, zh_required, _, _ = zh_entry + en_key, en_required, _, en_line = en_entry + differences = [] + if zh_key != en_key: + differences.append(f"section '{en_key}' vs zh-CN '{zh_key}'") + if zh_required != en_required: + if en_required is None: + differences.append("missing slot marker (zh-CN has one)") + elif zh_required is None: + differences.append("has slot marker while zh-CN does not") + else: + differences.append(f"marker {'required' if en_required else 'optional'} vs zh-CN {'required' if zh_required else 'optional'}") + if differences: + errors.append(error(en_path, en_line, "negotiation-alignment", f"Section {index + 1} diverges from zh-CN template ({'; '.join(differences)}).")) + return errors + + +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.")] + expected = { + 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 + } | { + Path("common") / "abort" / "v1" / language / "template.md" + for language in NEGOTIATION_LANGUAGES + } + 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: + errors.append(error(path, 1, "negotiation-file-set", f"Unexpected Negotiation-T template location: {relative}")) + continue + found.add(relative) + 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. + 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): + errors.append(error(negotiation_root / relative, 1, "negotiation-file-set", f"Missing Negotiation-T template: {negotiation_root / relative}")) + for type_segment in NEGOTIATION_TYPE_SEGMENTS: + for phase_segment in NEGOTIATION_PHASE_SEGMENTS: + zh_shape = shapes.get((type_segment, phase_segment, "zh-CN")) + en_shape = shapes.get((type_segment, phase_segment, "en-US")) + if zh_shape is None or en_shape is None: + continue + 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 + + +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.")] + if not slots.is_dir(): + return [error(slots, 1, "resource-root", "Missing slots directory.")] + errors: list[str] = [] + # 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}")) + 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) + 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: + print(f"A2A-T template lint failed with {len(errors)} error(s).", file=sys.stderr) + return 1 + print("A2A-T template lint passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())