提交一下文档
This commit is contained in:
598
docs/import/20260712/OPEN_AGENT_API_JAVA_SSE_CLIENT.md
Normal file
598
docs/import/20260712/OPEN_AGENT_API_JAVA_SSE_CLIENT.md
Normal file
@@ -0,0 +1,598 @@
|
|||||||
|
# SuperAgent Open API Java SSE 接入与断流恢复
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
本文档定义 Java 后端调用 SuperAgent Open API 流式接口时必须实现的客户端行为,重点解决以下问题:
|
||||||
|
|
||||||
|
- SSE 响应在 `answer`、结构化 `error` 或 `end` 前提前关闭。
|
||||||
|
- Java 抛出 `IOException: Premature EOF`、`EOFException` 或 incomplete chunked response。
|
||||||
|
- 网络中断后误把部分回答当成成功结果。
|
||||||
|
- 网络中断后重新 POST,造成同一业务消息被执行两次。
|
||||||
|
- 无法使用调用方 `debug_run_id` 关联 SuperAgent 内部 `run_id`。
|
||||||
|
|
||||||
|
适用接口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=true
|
||||||
|
GET /api/open/agent-sessions/{session_id}/runs/{run_id}/events
|
||||||
|
GET /api/open/agent-sessions/{session_id}/runs/{run_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
推荐运行环境:Java 17 或更高版本,JDK `java.net.http.HttpClient`,Jackson 2.x。
|
||||||
|
|
||||||
|
## 2. Java 端必须遵守的成功条件
|
||||||
|
|
||||||
|
Java 客户端只有在同时满足以下条件时才能把调用判定为成功:
|
||||||
|
|
||||||
|
1. 收到最终 AI 内容:优先使用 `message.final`,兼容累计 `message.delta`。
|
||||||
|
2. 收到 `run.completed`,并且 `status=success`。
|
||||||
|
3. 收到顶层 SSE `event: end`。
|
||||||
|
4. 没有收到顶层 `event: error` 或 `trace.data.event=run.failed`。
|
||||||
|
|
||||||
|
以下情况都不能返回成功:
|
||||||
|
|
||||||
|
- 只收到部分 `message.delta` 后 EOF。
|
||||||
|
- 收到 `run.completed`,但没有最终 AI 内容。
|
||||||
|
- 收到最终内容,但没有 `end`。
|
||||||
|
- HTTP 连接正常关闭,但未出现协议终止事件。
|
||||||
|
- 运行状态仍为 `pending`、`running`、`interrupted`、`timeout` 或 `error`。
|
||||||
|
|
||||||
|
## 3. 请求头与关联信息
|
||||||
|
|
||||||
|
推荐请求头:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer df_open_xxx
|
||||||
|
Accept: text/event-stream
|
||||||
|
Content-Type: application/json
|
||||||
|
Cache-Control: no-cache
|
||||||
|
X-Request-ID: 2076266481248989185
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以使用:
|
||||||
|
|
||||||
|
```http
|
||||||
|
X-DeerFlow-Open-API-Key: df_open_xxx
|
||||||
|
```
|
||||||
|
|
||||||
|
每次业务调用生成一个稳定的 `debug_run_id`,同时写入:
|
||||||
|
|
||||||
|
- `X-Request-ID` 请求头。
|
||||||
|
- 本地业务日志。
|
||||||
|
- 本地调用记录。
|
||||||
|
- 请求 `metadata.debug_run_id`,前提是外部应用策略允许该 metadata 字段。
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "用户问题",
|
||||||
|
"idempotency_key": "business-message-20260712-0001",
|
||||||
|
"metadata": {
|
||||||
|
"debug_run_id": "2076266481248989185",
|
||||||
|
"conversation_id": "conversation-001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`idempotency_key` 必须在同一业务消息的所有尝试中保持不变。服务端未确认幂等命中前,不得依赖它盲目重发初始 POST。
|
||||||
|
|
||||||
|
## 4. 超时配置
|
||||||
|
|
||||||
|
SSE 是长连接,不要给整个 `HttpRequest` 设置 30 秒或 60 秒总超时。
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
```java
|
||||||
|
HttpClient client = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
初始 SSE 请求不要调用:
|
||||||
|
|
||||||
|
```java
|
||||||
|
requestBuilder.timeout(Duration.ofSeconds(60));
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端应分别控制:
|
||||||
|
|
||||||
|
- 建连超时:10 秒左右。
|
||||||
|
- 首个事件超时:由业务层 watchdog 控制,例如 60 秒。
|
||||||
|
- 心跳空闲超时:建议 45~90 秒,必须大于服务端心跳间隔。
|
||||||
|
- 总业务执行超时:根据 Agent 任务类型配置,例如 10~30 分钟。
|
||||||
|
- 重连次数:建议 5 次。
|
||||||
|
|
||||||
|
`Premature EOF` 表示对端提前关闭响应,不是普通读取等待超时。单纯增大 read timeout 不能修复该问题。
|
||||||
|
|
||||||
|
## 5. 必须保存的流状态
|
||||||
|
|
||||||
|
每个运行至少保存:
|
||||||
|
|
||||||
|
```java
|
||||||
|
final class SseRunState {
|
||||||
|
final StringBuilder deltaContent = new StringBuilder();
|
||||||
|
String finalContent;
|
||||||
|
String lastEventId;
|
||||||
|
URI runUri;
|
||||||
|
String runId;
|
||||||
|
String failureCode;
|
||||||
|
String failureMessage;
|
||||||
|
boolean runCompleted;
|
||||||
|
boolean endReceived;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
状态建议在收到每个事件后同步写入本地调用记录,以便进程重启后仍能诊断。
|
||||||
|
|
||||||
|
## 6. SSE 解析要求
|
||||||
|
|
||||||
|
Java 客户端必须正确处理:
|
||||||
|
|
||||||
|
- `event:`:事件类型。
|
||||||
|
- `data:`:允许多行,使用换行拼接。
|
||||||
|
- `id:`:保存最后一个非空事件 ID。
|
||||||
|
- 以 `:` 开头的 heartbeat/comment。
|
||||||
|
- 空行:表示一帧 SSE 结束。
|
||||||
|
- EOF:若未收到 `end`,必须抛出可恢复的流中断异常。
|
||||||
|
|
||||||
|
不要通过搜索字符串 `"event: end"` 解析流;必须按 SSE 帧解析。
|
||||||
|
|
||||||
|
## 7. 事件处理规则
|
||||||
|
|
||||||
|
### 7.1 Trace 事件
|
||||||
|
|
||||||
|
```text
|
||||||
|
event: trace
|
||||||
|
data: {"event":"message.delta","text":"部分回答"}
|
||||||
|
```
|
||||||
|
|
||||||
|
处理方式:按事件 ID 去重后追加到 `deltaContent`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
event: trace
|
||||||
|
data: {"event":"message.final","text":"完整最终回答"}
|
||||||
|
```
|
||||||
|
|
||||||
|
处理方式:写入 `finalContent`。该事件是最终答案的权威来源,不要再把此前 delta 追加到它后面。
|
||||||
|
|
||||||
|
```text
|
||||||
|
event: trace
|
||||||
|
data: {"event":"run.completed","status":"success"}
|
||||||
|
```
|
||||||
|
|
||||||
|
处理方式:设置 `runCompleted=true`,但继续读取,直到收到顶层 `end`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
event: trace
|
||||||
|
data: {"event":"run.failed","error":{"code":"...","message":"..."}}
|
||||||
|
```
|
||||||
|
|
||||||
|
处理方式:记录失败信息,继续读取到 `end`,最终抛出业务异常。
|
||||||
|
|
||||||
|
### 7.2 顶层错误和结束事件
|
||||||
|
|
||||||
|
```text
|
||||||
|
event: error
|
||||||
|
data: {"code":"open_agent_sse_stream_error","message":"...","retryable":true}
|
||||||
|
|
||||||
|
event: end
|
||||||
|
data: null
|
||||||
|
```
|
||||||
|
|
||||||
|
收到 `error` 后不得返回已累计的部分回答。收到 `end` 后按第 2 节的成功条件判定最终结果。
|
||||||
|
|
||||||
|
## 8. 断流恢复流程
|
||||||
|
|
||||||
|
初始 POST 返回 HTTP 200 后,立即读取响应头:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Content-Location: /api/open/agent-sessions/{session_id}/runs/{run_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端必须保存:
|
||||||
|
|
||||||
|
- `runUri`:由请求基础地址解析 `Content-Location`。
|
||||||
|
- `runId`:从 `runUri` 提取。
|
||||||
|
- `lastEventId`:从最后一个完整 SSE 帧提取。
|
||||||
|
|
||||||
|
遇到 EOF、连接重置或 incomplete chunked response 时:
|
||||||
|
|
||||||
|
1. 不要重新发送初始消息 POST。
|
||||||
|
2. 查询 `GET {runUri}`。
|
||||||
|
3. 如果状态为 `pending` 或 `running`,订阅 `GET {runUri}/events`。
|
||||||
|
4. 重连请求携带 `Last-Event-ID`。
|
||||||
|
5. 使用指数退避和随机抖动,最多重连 5 次。
|
||||||
|
6. 如果状态为 `success`,但没有最终答案,仍尝试事件恢复;恢复失败则报告“运行成功但最终内容丢失”。
|
||||||
|
7. 如果状态为 `error`、`timeout` 或 `interrupted`,停止重连并返回明确失败。
|
||||||
|
|
||||||
|
重连请求:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/open/agent-sessions/{session_id}/runs/{run_id}/events
|
||||||
|
Authorization: Bearer df_open_xxx
|
||||||
|
Accept: text/event-stream
|
||||||
|
Last-Event-ID: 1752319583000-17
|
||||||
|
X-Request-ID: 2076266481248989185
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复接口要求外部应用具备 `agent_sessions:read` scope,并启用 trace policy。
|
||||||
|
|
||||||
|
## 9. JDK HttpClient 参考实现
|
||||||
|
|
||||||
|
以下代码展示核心逻辑。项目可按现有异常体系、日志框架和 DTO 规范拆分。
|
||||||
|
|
||||||
|
```java
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.EOFException;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
|
|
||||||
|
public final class SuperAgentSseClient {
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
private final HttpClient client = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
public String invoke(
|
||||||
|
URI streamUri,
|
||||||
|
String apiKey,
|
||||||
|
String requestId,
|
||||||
|
String requestJson
|
||||||
|
) throws Exception {
|
||||||
|
HttpRequest request = baseRequest(streamUri, apiKey, requestId)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<InputStream> response = client.send(
|
||||||
|
request,
|
||||||
|
HttpResponse.BodyHandlers.ofInputStream()
|
||||||
|
);
|
||||||
|
require2xx(response.statusCode());
|
||||||
|
|
||||||
|
String contentLocation = response.headers()
|
||||||
|
.firstValue("Content-Location")
|
||||||
|
.orElseThrow(() -> new IOException("Missing Content-Location"));
|
||||||
|
|
||||||
|
SseRunState state = new SseRunState();
|
||||||
|
state.runUri = streamUri.resolve(contentLocation);
|
||||||
|
state.runId = state.runUri.getPath().replaceFirst(".*/runs/", "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
consume(response.body(), state);
|
||||||
|
} catch (IOException firstFailure) {
|
||||||
|
recover(apiKey, requestId, state, firstFailure);
|
||||||
|
}
|
||||||
|
|
||||||
|
return requireSuccessfulResult(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recover(
|
||||||
|
String apiKey,
|
||||||
|
String requestId,
|
||||||
|
SseRunState state,
|
||||||
|
IOException firstFailure
|
||||||
|
) throws Exception {
|
||||||
|
IOException lastFailure = firstFailure;
|
||||||
|
URI eventsUri = URI.create(
|
||||||
|
state.runUri.toString().replaceAll("/+$", "") + "/events"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (int attempt = 1; attempt <= 5; attempt++) {
|
||||||
|
Thread.sleep(backoffMillis(attempt));
|
||||||
|
|
||||||
|
String status = queryRunStatus(
|
||||||
|
state.runUri, apiKey, requestId
|
||||||
|
);
|
||||||
|
if ("error".equals(status)
|
||||||
|
|| "timeout".equals(status)
|
||||||
|
|| "interrupted".equals(status)) {
|
||||||
|
throw new IOException(
|
||||||
|
"SuperAgent run is terminal: runId=" + state.runId
|
||||||
|
+ ", status=" + status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpRequest.Builder builder = baseRequest(
|
||||||
|
eventsUri, apiKey, requestId
|
||||||
|
).GET();
|
||||||
|
if (state.lastEventId != null) {
|
||||||
|
builder.header("Last-Event-ID", state.lastEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpResponse<InputStream> response = client.send(
|
||||||
|
builder.build(),
|
||||||
|
HttpResponse.BodyHandlers.ofInputStream()
|
||||||
|
);
|
||||||
|
require2xx(response.statusCode());
|
||||||
|
consume(response.body(), state);
|
||||||
|
if (state.endReceived) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (IOException failure) {
|
||||||
|
lastFailure = failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IOException(
|
||||||
|
"SuperAgent SSE recovery exhausted; runId=" + state.runId
|
||||||
|
+ ", lastEventId=" + state.lastEventId,
|
||||||
|
lastFailure
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String queryRunStatus(
|
||||||
|
URI runUri,
|
||||||
|
String apiKey,
|
||||||
|
String requestId
|
||||||
|
) throws IOException, InterruptedException {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder(runUri)
|
||||||
|
.header("Authorization", "Bearer " + apiKey)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("X-Request-ID", requestId)
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = client.send(
|
||||||
|
request,
|
||||||
|
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
|
||||||
|
);
|
||||||
|
require2xx(response.statusCode());
|
||||||
|
return mapper.readTree(response.body()).path("status").asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void consume(InputStream input, SseRunState state)
|
||||||
|
throws IOException {
|
||||||
|
try (BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(input, StandardCharsets.UTF_8)
|
||||||
|
)) {
|
||||||
|
String event = "message";
|
||||||
|
String id = null;
|
||||||
|
StringBuilder data = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
if (line.startsWith(":")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.isEmpty()) {
|
||||||
|
dispatch(event, id, data.toString(), state);
|
||||||
|
event = "message";
|
||||||
|
id = null;
|
||||||
|
data.setLength(0);
|
||||||
|
if (state.endReceived) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.startsWith("event:")) {
|
||||||
|
event = line.substring(6).trim();
|
||||||
|
} else if (line.startsWith("id:")) {
|
||||||
|
id = line.substring(3).trim();
|
||||||
|
} else if (line.startsWith("data:")) {
|
||||||
|
if (data.length() > 0) {
|
||||||
|
data.append('\n');
|
||||||
|
}
|
||||||
|
data.append(line.substring(5).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.endReceived) {
|
||||||
|
throw new EOFException(
|
||||||
|
"Premature EOF before SSE end; runId=" + state.runId
|
||||||
|
+ ", lastEventId=" + state.lastEventId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatch(
|
||||||
|
String event,
|
||||||
|
String id,
|
||||||
|
String data,
|
||||||
|
SseRunState state
|
||||||
|
) throws IOException {
|
||||||
|
if (id != null && !id.isBlank()) {
|
||||||
|
if (!state.processedEventIds.add(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.lastEventId = id;
|
||||||
|
}
|
||||||
|
if ("end".equals(event)) {
|
||||||
|
state.endReceived = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("error".equals(event)) {
|
||||||
|
JsonNode error = parseJson(data);
|
||||||
|
state.failureCode = error.path("code").asText("stream_error");
|
||||||
|
state.failureMessage = error.path("message").asText(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!"trace".equals(event) || data.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode trace = parseJson(data);
|
||||||
|
switch (trace.path("event").asText()) {
|
||||||
|
case "message.delta" ->
|
||||||
|
state.deltaContent.append(trace.path("text").asText(""));
|
||||||
|
case "message.final" ->
|
||||||
|
state.finalContent = trace.path("text").asText("");
|
||||||
|
case "run.completed" ->
|
||||||
|
state.runCompleted = "success".equals(
|
||||||
|
trace.path("status").asText()
|
||||||
|
);
|
||||||
|
case "run.failed" -> {
|
||||||
|
JsonNode error = trace.path("error");
|
||||||
|
state.failureCode = error.path("code")
|
||||||
|
.asText("run_failed");
|
||||||
|
state.failureMessage = error.path("message")
|
||||||
|
.asText(error.toString());
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
// reasoning/tool/progress/task/step 事件按业务需要记录。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode parseJson(String data) throws IOException {
|
||||||
|
return data == null || data.isBlank()
|
||||||
|
? mapper.createObjectNode()
|
||||||
|
: mapper.readTree(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpRequest.Builder baseRequest(
|
||||||
|
URI uri,
|
||||||
|
String apiKey,
|
||||||
|
String requestId
|
||||||
|
) {
|
||||||
|
return HttpRequest.newBuilder(uri)
|
||||||
|
.header("Authorization", "Bearer " + apiKey)
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
|
.header("Cache-Control", "no-cache")
|
||||||
|
.header("X-Request-ID", requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireSuccessfulResult(SseRunState state)
|
||||||
|
throws IOException {
|
||||||
|
if (state.failureCode != null) {
|
||||||
|
throw new IOException(
|
||||||
|
"SuperAgent failed: " + state.failureCode + ": "
|
||||||
|
+ state.failureMessage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!state.endReceived) {
|
||||||
|
throw new EOFException("Missing terminal end event");
|
||||||
|
}
|
||||||
|
if (!state.runCompleted) {
|
||||||
|
throw new IOException("Missing successful run.completed event");
|
||||||
|
}
|
||||||
|
String answer = state.finalContent;
|
||||||
|
if (answer == null || answer.isBlank()) {
|
||||||
|
answer = state.deltaContent.toString();
|
||||||
|
}
|
||||||
|
if (answer.isBlank()) {
|
||||||
|
throw new IOException(
|
||||||
|
"Run completed but final AI content was not delivered"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void require2xx(int statusCode) throws IOException {
|
||||||
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
|
throw new IOException("Unexpected HTTP status: " + statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long backoffMillis(int attempt) {
|
||||||
|
long base = Math.min(8_000L, 500L << (attempt - 1));
|
||||||
|
return base + ThreadLocalRandom.current().nextLong(200L);
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class SseRunState {
|
||||||
|
final StringBuilder deltaContent = new StringBuilder();
|
||||||
|
final Set<String> processedEventIds = new HashSet<>();
|
||||||
|
String finalContent;
|
||||||
|
String lastEventId;
|
||||||
|
URI runUri;
|
||||||
|
String runId;
|
||||||
|
String failureCode;
|
||||||
|
String failureMessage;
|
||||||
|
boolean runCompleted;
|
||||||
|
boolean endReceived;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Jackson Maven 依赖示例:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
<version>${jackson.version}</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. 去重规则
|
||||||
|
|
||||||
|
- 使用 SSE `id` 对事件去重。
|
||||||
|
- 重连后收到小于或等于已处理 ID 的事件时跳过。
|
||||||
|
- `message.final` 按 `run_id` 只处理一次。
|
||||||
|
- `end` 可重复处理,但只能完成一次本地 Future/Promise。
|
||||||
|
- 不要仅根据文本内容去重;不同 delta 可能具有相同文本。
|
||||||
|
|
||||||
|
如果使用多个 Java 实例消费同一业务调用,`run_id + event_id` 的去重状态必须存放在共享存储中。
|
||||||
|
|
||||||
|
## 11. 日志与指标
|
||||||
|
|
||||||
|
每次调用至少记录:
|
||||||
|
|
||||||
|
- `debug_run_id` / `X-Request-ID`。
|
||||||
|
- `session_id`。
|
||||||
|
- SuperAgent `run_id`。
|
||||||
|
- 最后处理的 `event_id`。
|
||||||
|
- HTTP 状态码。
|
||||||
|
- 收到的首事件时间、最后事件时间和 `end` 时间。
|
||||||
|
- 重连次数和每次重连原因。
|
||||||
|
- 是否收到 `message.final`、`run.completed`、`error`、`end`。
|
||||||
|
- 最终结果:success、run_failed、stream_interrupted、recovery_exhausted、protocol_incomplete。
|
||||||
|
|
||||||
|
建议指标:
|
||||||
|
|
||||||
|
```text
|
||||||
|
superagent_sse_requests_total
|
||||||
|
superagent_sse_success_total
|
||||||
|
superagent_sse_premature_eof_total
|
||||||
|
superagent_sse_reconnect_total
|
||||||
|
superagent_sse_recovery_success_total
|
||||||
|
superagent_sse_missing_end_total
|
||||||
|
superagent_sse_missing_final_content_total
|
||||||
|
superagent_sse_duration_seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
日志中不要记录 API Key、完整系统提示词或敏感工具输入输出。
|
||||||
|
|
||||||
|
## 12. 验收测试
|
||||||
|
|
||||||
|
Java 端上线前至少覆盖:
|
||||||
|
|
||||||
|
1. 正常 delta → final → completed → end。
|
||||||
|
2. 无 delta,直接 final → completed → end。
|
||||||
|
3. 收到结构化 error → end。
|
||||||
|
4. 收到部分 delta 后 EOF,使用 `Last-Event-ID` 恢复成功。
|
||||||
|
5. 重连期间再次 EOF,指数退避后恢复成功。
|
||||||
|
6. 重连超过最大次数,返回 recovery exhausted。
|
||||||
|
7. completed 但没有 final/delta,返回协议错误。
|
||||||
|
8. 有 final 但没有 end,不能返回成功。
|
||||||
|
9. 事件重放时不会重复拼接 delta。
|
||||||
|
10. API 返回 401、403、404、409、429、5xx。
|
||||||
|
11. Java 进程取消请求时,本地状态正确关闭。
|
||||||
|
12. 同一业务消息不会因 EOF 自动重新 POST。
|
||||||
|
|
||||||
|
## 13. 上线顺序
|
||||||
|
|
||||||
|
1. 先部署服务端 SSE 终止契约、断连继续执行、最终答案事件和 nginx 配置。
|
||||||
|
2. 在测试环境验证 `/events` 支持 `Last-Event-ID` 恢复。
|
||||||
|
3. Java 端以灰度方式开启严格 `end` 校验和恢复逻辑。
|
||||||
|
4. 观察 premature EOF、恢复成功率和缺失最终内容指标。
|
||||||
|
5. 指标稳定后全量启用。
|
||||||
|
|
||||||
|
服务端升级前,Java 可以先完成解析、终止帧校验、关联日志和错误分类,但不要启用自动重发初始 POST,也不能假设 EOF 后原运行仍会继续。
|
||||||
357
docs/import/20260712/前端字段控件修改说明_给信息系统小伙伴Codex_2026-07-12.md
Normal file
357
docs/import/20260712/前端字段控件修改说明_给信息系统小伙伴Codex_2026-07-12.md
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
# 前端字段控件修改说明
|
||||||
|
|
||||||
|
## 给信息系统 / Adapter / Frontend Codex 的实施任务
|
||||||
|
|
||||||
|
日期:2026-07-12
|
||||||
|
适用范围:任务卡展示、人工复核编辑、字段校验和同卡解阻
|
||||||
|
不适用范围:Prompt、Skill、业务 references、MCP Server、Gateway、公开 JSON 业务规则重设计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 先读这些文件
|
||||||
|
|
||||||
|
请先从当前工作区根目录读取以下文件,再开始修改:
|
||||||
|
|
||||||
|
1. `prompt_skill_architecture_optimization_2026-07-12/README.md`
|
||||||
|
2. `prompt_skill_architecture_optimization_2026-07-12/01_PLAN/compatibility_invariants.md`
|
||||||
|
3. `prompt_skill_architecture_optimization_2026-07-12/03_VALIDATION/draft_validation_2026-07-12.md`
|
||||||
|
4. `outputs/黄哥codex读 2/README_先读_交给Codex.md`
|
||||||
|
5. `outputs/黄哥codex读 2/04_Adapter_Frontend_保持P0.1_40路由/任务卡前端字段变更与路由说明_3.0_to_current.md`
|
||||||
|
6. `outputs/黄哥codex读 2/04_Adapter_Frontend_保持P0.1_40路由/任务卡前端字段变更说明_3.0_to_当前版_2026-07-11.xlsx`
|
||||||
|
7. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/00-output-contract.md`
|
||||||
|
8. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/90-manual-review.md`
|
||||||
|
9. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/16-trace-notes.md`
|
||||||
|
10. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/31-allotment-control-block.md`
|
||||||
|
11. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/50-room-type-mapping.md`
|
||||||
|
12. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/51-rate-code.md`
|
||||||
|
13. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/52-fix-charge.md`
|
||||||
|
|
||||||
|
旧文件 `任务卡前端展示字段表 3.0(1).xlsx` 只作为历史基线,不得作为当前字段、路由和枚举的唯一来源。
|
||||||
|
|
||||||
|
如果上述交接文件或实际 Adapter/Frontend 源码不在当前工作区,请停止代码修改并报告缺失路径;不要根据旧 Excel 猜测当前字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 任务目标
|
||||||
|
|
||||||
|
把任务卡前端从旧版“是否输入 / 是否下拉”字段说明,升级为当前项目可执行的控件注册和人工复核编辑机制:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Agent final result
|
||||||
|
→ adapter 按 event 派生任务卡
|
||||||
|
→ manual_review.missing_fields[] 指定可编辑字段
|
||||||
|
→ 前端展示对应控件
|
||||||
|
→ 用户修改
|
||||||
|
→ review_resolution.field_overrides[]
|
||||||
|
→ 重新校验依赖字段
|
||||||
|
→ Preflight
|
||||||
|
```
|
||||||
|
|
||||||
|
核心要求:
|
||||||
|
|
||||||
|
- 原 Agent payload 不可变。
|
||||||
|
- 用户覆盖值不得直接写回 Agent 原始 JSON。
|
||||||
|
- 不因为前端编辑而改变 `event_type`、业务 task type、task subtype 或关系语义。
|
||||||
|
- 不创建第二张 linked normal task。
|
||||||
|
- 只有同一业务卡的 `review_status` 从 `pending` 进入 `resolved` 后,才允许继续 Preflight。
|
||||||
|
- 邮件原文、附件、raw evidence、source ID、source event index 和关系索引只读。
|
||||||
|
|
||||||
|
当前项目的最新 Prompt/Skill 架构增量仍处于草案、未进入 `04_RELEASE` 状态;本任务只修改信息系统展示/编辑层,不把草案直接宣称为生产发布。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 修改范围
|
||||||
|
|
||||||
|
### 允许修改
|
||||||
|
|
||||||
|
- Adapter 的字段注册表 / 字段映射层。
|
||||||
|
- 任务卡展示组件和人工复核组件。
|
||||||
|
- 控件类型、编辑权限和字段校验逻辑。
|
||||||
|
- `review_status` / `review_resolution` 的前端交互。
|
||||||
|
- Adapter/Frontend 单元测试、契约测试和验收用例。
|
||||||
|
- 当前字段交接 MD/Excel 的控件列和实现说明。
|
||||||
|
|
||||||
|
### 不得修改
|
||||||
|
|
||||||
|
- `prompts/`、`skills/`、`booking-desk-event` Skill 业务裁决逻辑。
|
||||||
|
- 公开业务 JSON 根结构和既有五字段业务根。
|
||||||
|
- S10、S99、`infrastructure_input_error` 结构。
|
||||||
|
- 40 条 Adapter 路由数量和既有 task type/subtype 语义。
|
||||||
|
- MCP 工具名、MCP payload mapping、一次提交生命周期。
|
||||||
|
- `source_event_index` 的含义。
|
||||||
|
- Parent Group / Allotment 业务本体。
|
||||||
|
- 不得在前端新增 `Note`、`Allotment Maintenance` 或旧 Parent Cancel Booking 当前路由。
|
||||||
|
|
||||||
|
如果实现过程中发现必须改变上述内容,请先报告为“契约变更”,不要在前端代码中自行绕过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 前端控件类型
|
||||||
|
|
||||||
|
字段注册表至少应支持以下 `control_type`。名称可按现有代码风格调整,但语义必须保持一致:
|
||||||
|
|
||||||
|
| `control_type` | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `readonly` | Agent 结果、raw evidence、路由、关系和审计字段 |
|
||||||
|
| `text` | Group Code、Confirmation No.、备注、旧/新 Group Code |
|
||||||
|
| `textarea` | 允许覆盖的业务备注;必须保留原始证据 |
|
||||||
|
| `number` | 房量、人数、PAX、结算价、附加费金额 |
|
||||||
|
| `date` | 入住日、离店日、服务日期、取消生效日期 |
|
||||||
|
| `lookup` | 可输入并检索当前系统目标对象的组合框 |
|
||||||
|
| `select` | 固定枚举或目录选择 |
|
||||||
|
| `multiselect` | 多部门、多标签、多项业务分类 |
|
||||||
|
| `structured_table` | `before_after[]`、`room_items[]`、`fix_charge_items[]`、`trace_items[]` |
|
||||||
|
| `workflow_state` | `review_status`、Resolve/重新校验等动作 |
|
||||||
|
|
||||||
|
字段注册表还应增加:
|
||||||
|
|
||||||
|
| 配置项 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `edit_scope` | `never`、`manual_review_only`、`workflow_only`、`system_only` |
|
||||||
|
| `options_source` | 固定契约枚举、当前 PMS 目录、Rate 配置中心、无、待冻结 |
|
||||||
|
| `write_target` | `review_resolution.field_overrides`、系统状态、无 |
|
||||||
|
| `validation_rule` | 格式、必填、目录、依赖字段和 Preflight 前置校验 |
|
||||||
|
| `raw_readonly` | 是否必须保留原始值且禁止覆盖 |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"field_pointer": "/extracted_fields/pms_room_type_code",
|
||||||
|
"control_type": "select",
|
||||||
|
"edit_scope": "manual_review_only",
|
||||||
|
"options_source": "active_pms_room_type_catalog",
|
||||||
|
"write_target": "review_resolution.field_overrides",
|
||||||
|
"raw_readonly": true,
|
||||||
|
"validation_rule": "catalog_value_exists_and_is_active"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 字段控件实施矩阵
|
||||||
|
|
||||||
|
### 4.1 目标、身份和 Group Code
|
||||||
|
|
||||||
|
| 字段 | 控件 | 编辑范围 | 实施要求 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `case_keys.group_code` | `lookup` | `manual_review_only` | 可输入并检索候选;校验目标存在、唯一和当前业务卡匹配 |
|
||||||
|
| `case_keys.confirmation_number` | `lookup` 或 `text` | `manual_review_only` | 不使用固定下拉;允许粘贴后执行格式/目标校验 |
|
||||||
|
| `case_keys.reservation_number` | `lookup` 或 `text` | `manual_review_only` | 与当前事件对象类型匹配后才能提交 |
|
||||||
|
| Parent `case_keys.block_code` | `readonly` | `never` | 必须与 Parent `group_code` 相等;禁止独立编辑 |
|
||||||
|
| `extracted_fields.old_group_code` | `text` | `manual_review_only` | 与新 Group Code 成对必填 |
|
||||||
|
| `extracted_fields.new_group_code` | `text` | `manual_review_only` | 必须表达 old → new 方向关系 |
|
||||||
|
| `extracted_fields.target_key` | 不再作为主控件 | — | 当前应优先使用通用 `case_keys`;旧字段只做兼容迁移 |
|
||||||
|
|
||||||
|
### 4.2 日期、房量和房型
|
||||||
|
|
||||||
|
| 字段 | 控件 | 编辑范围 | 实施要求 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `extracted_fields.arrival_date` | `date` | `manual_review_only` | 使用 `YYYY-MM-DD`;修改后重新校验离店日和晚数 |
|
||||||
|
| `extracted_fields.departure_date` | `date` | `manual_review_only` | 必须晚于入住日;保留原始日期证据 |
|
||||||
|
| `extracted_fields.nights` | `readonly` | `never` | 由入住日和离店日计算,不直接输入 |
|
||||||
|
| `extracted_fields.date_evidence.*` | `readonly` | `never` | `hotel_date_raw`、`tour_date_raw`、`action_date_raw`、`sheet_month_year` 只读 |
|
||||||
|
| `extracted_fields.room_quantity` | `number` | `manual_review_only` | 整数、非负/正数规则按现有业务字段执行 |
|
||||||
|
| `extracted_fields.child_room_items[]` | `structured_table` | `manual_review_only` | 每个 Child 的房型、房量和 PMS code 分列;禁止单行自由文本 |
|
||||||
|
| `extracted_fields.room_type_raw` | `readonly` | `never` | 原始房型文本不可被用户覆盖 |
|
||||||
|
| `extracted_fields.pms_room_type_code` | `select` | `manual_review_only` | 只能从当前有效 PMS 房型目录选择;禁止自由输入 code |
|
||||||
|
|
||||||
|
Parent 原始房量汇总与 Child 房型/房量汇总可以因分配或房型重分配而不同。前端不得仅因数量不相等显示冲突、强制修改或自动创建人工复核。
|
||||||
|
|
||||||
|
### 4.3 业务路由和分类字段
|
||||||
|
|
||||||
|
以下字段是业务识别或 Adapter 路由判别字段,默认只读:
|
||||||
|
|
||||||
|
| 字段 | 控件 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `event_type` | `readonly` | 前端不允许 New / Update / Cancel 之间切换 |
|
||||||
|
| `booking_object_type` | `readonly` | FIT、Group Block、Allotment / Control Block 由业务结果确定 |
|
||||||
|
| `cancel_object_type` | `readonly` 或同路由受控 `select` | Cancel Booking 只接受 FIT/普通 Group;Allotment 整块取消属于 Cancel Allotment |
|
||||||
|
| `cancel_scope` | `readonly` 或同路由受控 `select` | 不使用自由文本;不得用前端选择跨到另一张任务卡 |
|
||||||
|
| `update_actions[]` / 旧 `update_subtypes[]` | `readonly` 标签 | 当前 Update 统一走 `update_booking_amendment`;动作由结果派生,不能用下拉改路由 |
|
||||||
|
| `voucher_subtype` | `readonly` | `lian_tai_credit_voucher` 与 `Payment Evidence` 分开,不跨卡选择 |
|
||||||
|
| `list_evidence_type` | `readonly` 或复核时受控 `select` | 不允许通过它自行创建新任务类型 |
|
||||||
|
| `trace_subtype` | `readonly` | 由 `trace_items[]` 重新判断 `extra_bed` / `general_request` |
|
||||||
|
|
||||||
|
如果 `manual_review.missing_fields[]` 明确指向同一业务卡的分类字段,才可以显示受控选择;选择结果不得自动改变 `event_type` 或 Adapter 路由,除非项目另行冻结了跨路由 resolution 契约。
|
||||||
|
|
||||||
|
### 4.4 Update 和 Before/After
|
||||||
|
|
||||||
|
`extracted_fields.before_after[]` 使用 `structured_table`:
|
||||||
|
|
||||||
|
| 子字段 | 控件 |
|
||||||
|
|---|---|
|
||||||
|
| `field` | 当前支持字段的受控选择;不得任意输入路径 |
|
||||||
|
| `before` | 只读原值/证据值 |
|
||||||
|
| `after` | 按字段类型使用日期、数字、文字或目录选择 |
|
||||||
|
| `evidence` | 只读 |
|
||||||
|
|
||||||
|
如果 Update 类型和 subtype 已确定,但 before/after 不可靠,保留原 Update 卡并进入同卡人工复核;不能把它改成 Fallback,也不能创建第二张卡。
|
||||||
|
|
||||||
|
### 4.5 Rate Code 和结算价
|
||||||
|
|
||||||
|
| 字段 | 控件 | 实施要求 |
|
||||||
|
|---|---|---|
|
||||||
|
| `extracted_fields.rate_code_result.rate_code` | 配置驱动 `select/lookup` | 不得硬编码旧 3.0 的 20+ code 全集;从当前 Rate 配置中心加载 |
|
||||||
|
| `rate_code_raw` | `readonly` | 原始价格/Rate marker 只读;路径未冻结时不要自行发明公开路径 |
|
||||||
|
| `settlement_price` | `number` | 单独编辑;校验币种、精度、非负和业务依赖 |
|
||||||
|
| `manual_settlement_price_required` | `readonly` | AI/规则派生,不让用户直接改成 true/false |
|
||||||
|
| `manual_price_reason_code` / `price_evidence` | `readonly` | 用于解释和审计,不是输入项 |
|
||||||
|
|
||||||
|
如果 Rate Code 无法唯一确定,用户应选择当前有效目录值或填写允许的手工结算价;不能让用户输入一个未经配置的任意 Rate Code。
|
||||||
|
|
||||||
|
### 4.6 Fix Charge
|
||||||
|
|
||||||
|
`fix_charge_items[]` 必须做成 `structured_table`,不能压成一行输入。
|
||||||
|
|
||||||
|
| 子字段 | 控件 | 当前边界 |
|
||||||
|
|---|---|---|
|
||||||
|
| `charge_type` | `select` | 使用当前契约枚举,如 `fixed_charge`;不要继续使用旧表 `additional_charge` |
|
||||||
|
| `pricing_mode` | `select` | 使用当前契约 `unit` 等已冻结值;旧 `unit_price` 需迁移 |
|
||||||
|
| `amount` / `quantity` / `total_amount` | `number` | 不把多个金额拼成文本;不得前端自行推导未经契约支持的总价 |
|
||||||
|
| `currency` | `select` 或配置值 | 以当前系统货币目录为准 |
|
||||||
|
| `unit_basis` | `select` 或 `text` | 若枚举未冻结,先只读或容错,不猜选项 |
|
||||||
|
| `raw_text` / `evidence_source` | `readonly` | 保留原文和证据 |
|
||||||
|
| `requires_followup_tool` / `followup_tool_name` | `readonly` | 前端不得自行调用工具 |
|
||||||
|
| `additional_operations[]` | `readonly` / pending badge | 只展示待处理意图,不执行实际写入 |
|
||||||
|
|
||||||
|
### 4.7 Cancel
|
||||||
|
|
||||||
|
| 字段 | 控件 | 实施要求 |
|
||||||
|
|---|---|---|
|
||||||
|
| `cancel_object_type` | 默认 `readonly` | Cancel Booking 只显示 FIT/Group;Allotment 使用独立 Cancel Allotment 卡 |
|
||||||
|
| `cancel_scope` | 默认 `readonly` | 不允许自由文本;部分配额维护当前不支持 |
|
||||||
|
| `requested_cancel_effective_date` | `date` | 只有 `missing_fields[]` 指定时启用 |
|
||||||
|
| `cancellation_note` | `textarea` | 允许覆盖时必须保留原始邮件证据并记录 field override |
|
||||||
|
|
||||||
|
### 4.8 Voucher、Payment 和 Invoice
|
||||||
|
|
||||||
|
| 字段 | 控件 | 实施要求 |
|
||||||
|
|---|---|---|
|
||||||
|
| `voucher_attachment` / `file_reference` | `readonly` 文件展示 | 不允许改写输入文件引用 |
|
||||||
|
| `voucher_subtype` | `readonly` | Credit Voucher 与 Payment Evidence 分开路由 |
|
||||||
|
| `department_routing[]` | `multiselect` 或只读 | 当前路径仍需以实际 schema 为准;默认 Finance/pending 只展示,不执行确认 |
|
||||||
|
| `post_confirmation_intent` | `readonly` | 只表示待确认后的意图,不表示已确认或已执行 |
|
||||||
|
| Invoice 专属字段 | 默认 `readonly` | 当前详细 item schema 尚未完全冻结,不要自造输入字段 |
|
||||||
|
|
||||||
|
### 4.9 Trace、Parent 和关系字段
|
||||||
|
|
||||||
|
| 字段 | 控件 | 实施要求 |
|
||||||
|
|---|---|---|
|
||||||
|
| `extracted_fields.trace_text` | `readonly` | 保留完整原文,不允许直接编辑 |
|
||||||
|
| `trace_items[].category` | 固定枚举 `select` | 仅在明确 pointer/权限允许时修正 |
|
||||||
|
| `trace_items[].text_raw` | `readonly` | 原始补充信息不可改 |
|
||||||
|
| `trace_items[].service_date` | `date` | 只有需要且 pointer 明确时编辑 |
|
||||||
|
| `trace_items[].pax` | `number` | 缺失时可为空;不因缺失自动人工复核 |
|
||||||
|
| `trace_items[].notify_departments[]` | `multiselect` | 已知时可选 FO/HSK;不清楚时允许空数组 |
|
||||||
|
| `related_source_event_index` | `readonly` | 单一关系索引,只用于本次结果内部关联 |
|
||||||
|
| `related_source_event_indices[]` | `readonly` | Parent 或跨 Child Trace 的完整关系;禁止用户编辑索引 |
|
||||||
|
| `relationship_type` / `related_event_type` | `readonly` | 关系类型不选择任务卡 subtype |
|
||||||
|
| `requires_downstream_hard_validation` | `readonly` badge | 只展示系统硬校验要求 |
|
||||||
|
|
||||||
|
完整 Parent split 的全部 Child 补充请求仍使用现有 Trace 卡,通过 `related_source_event_indices[]` 展示全部 Child;不能只绑定 E1,也不能新增一个 Trace 路由。
|
||||||
|
|
||||||
|
### 4.10 Rooming List 和 TA Recorder
|
||||||
|
|
||||||
|
| 字段 | 控件 | 实施要求 |
|
||||||
|
|---|---|---|
|
||||||
|
| Rooming List target | `lookup` | 以通用 `case_keys` 为主;多个目标分开处理 |
|
||||||
|
| `list_evidence_type` | 默认只读 | 由证据识别,不允许任意切换卡型 |
|
||||||
|
| TA Recorder `group_code` | `lookup` / `text` | 只有缺失且 pointer 明确时编辑 |
|
||||||
|
| `ta_recorder_status` | `readonly` / `workflow_state` | 由信息系统后续状态更新,不是 AI 字段输入 |
|
||||||
|
| `source_rooming_list_task_id` | `readonly` 或隐藏 | 仅作 linked task 上下文 |
|
||||||
|
|
||||||
|
### 4.11 Manual Review 和系统字段
|
||||||
|
|
||||||
|
| 字段 | 控件 | 实施要求 |
|
||||||
|
|---|---|---|
|
||||||
|
| `manual_review.reason_code` | `readonly` badge | 不能由用户随意改原因 |
|
||||||
|
| `manual_review.visible_reason` | `readonly` | 只读说明 |
|
||||||
|
| `manual_review.blocking_points` | `readonly` list | 只读说明 |
|
||||||
|
| `manual_review.conflicting_points` | `readonly` list | 只读说明 |
|
||||||
|
| `manual_review.suggested_human_actions` | `readonly` list | 可转成页面操作提示,但不能直接当字段输入 |
|
||||||
|
| `manual_review.evidence_to_check` | `readonly` list | 引导查看证据 |
|
||||||
|
| `manual_review.known_fields` | `readonly` | 不直接编辑对象文本 |
|
||||||
|
| `manual_review.missing_fields[]` | 内部 pointer | 驱动控件,不作为普通输入框展示 |
|
||||||
|
| `review_status` | `workflow_state` | `pending → resolved`;只能通过完成字段校验后 Resolve |
|
||||||
|
| `review_resolution` | 系统生成、只读审计 | 保存 field overrides、用户和时间,不允许页面直接覆盖历史记录 |
|
||||||
|
|
||||||
|
### 4.12 只读字段
|
||||||
|
|
||||||
|
以下字段不得提供普通编辑控件:
|
||||||
|
|
||||||
|
- `source_message`、`source_message_id`、邮件主题、发件人、接收时间。
|
||||||
|
- `message_events[].event_type`、`event_role`、`current_or_history`、`source_event_index`。
|
||||||
|
- `attachments[]`、`file_references[]`、`context_used`、QBD sheet/row/highlight/raw evidence。
|
||||||
|
- `relevant_message_excerpt`、`text_raw`、`room_type_raw`、价格 raw marker。
|
||||||
|
- `unhandled_current_intents[]` 的原文和可见说明。
|
||||||
|
- S10、S99 和 `infrastructure_input_error` 的 route/status/error 字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 必须修正的旧 3.0 字段/枚举
|
||||||
|
|
||||||
|
请不要直接复制旧 Excel 的以下定义:
|
||||||
|
|
||||||
|
1. `Cancel Booking.cancel_object_type` 不再包含 `allotment_control_block`;整块 Parent/Allotment 取消使用 `Cancel Allotment`。
|
||||||
|
2. `Voucher Received.voucher_subtype` 不再包含 `bank_transfer_slip`;银行/现金/交易回执使用 `Payment Evidence`。
|
||||||
|
3. `Update Booking.extracted_fields.update_subtypes[]` 的旧长下拉不再作为路由选择;当前 Update 统一为 `update_booking_amendment`,动作只展示为结果标签。
|
||||||
|
4. `room_type` 不可作为自由修改后的唯一值;必须保留 raw,并用 PMS 目录选择 `pms_room_type_code`。
|
||||||
|
5. Rate Code 不得硬编码旧表中的完整 code 列表。
|
||||||
|
6. `Trace Text` 不得直接编辑原文。
|
||||||
|
7. Fix Charge 的旧 `additional_charge / unit_price` 与当前 `fixed_charge / unit` 语义不一致,必须以当前契约和实际 schema 为准。
|
||||||
|
8. `target_key`、`parent_source_event_index`、`post_confirm_action` 是旧字段/旧命名,需迁移到当前 `case_keys`、`related_source_event_index(es)` 和 `post_confirmation_intent`。
|
||||||
|
9. 不得继续生成或路由 `Note`、`Allotment Maintenance`、`Cancel Booking + linked_parent_release_after_child_split`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 必须通过的验收用例
|
||||||
|
|
||||||
|
至少增加或复用以下测试:
|
||||||
|
|
||||||
|
| 编号 | 场景 | 必须验证 |
|
||||||
|
|---|---|---|
|
||||||
|
| UI-01 | generic `SUITE` 房型 | raw 只读;PMS code 使用当前目录下拉;未选时保留 manual review |
|
||||||
|
| UI-02 | 入住/离店日期修改 | 日期控件有效;`nights` 自动重算;非法日期阻止 Resolve |
|
||||||
|
| UI-03 | Rate Code | 不读取旧硬编码全集;使用配置目录;手工价独立数字输入 |
|
||||||
|
| UI-04 | Fix Charge | `fixed_charge/unit` 枚举正确;金额/数量为数字;raw/evidence 只读 |
|
||||||
|
| UI-05 | Cancel 路由 | `Cancel Booking` 不出现 Allotment;Allotment 使用 Cancel Allotment |
|
||||||
|
| UI-06 | Voucher/Payment | bank slip 不可从 Voucher 卡下拉选择;Payment Evidence 单独显示 |
|
||||||
|
| UI-07 | Update | 不允许用 `update_subtypes` 下拉切换业务卡型;before/after 结构化展示 |
|
||||||
|
| UI-08 | Parent key | Parent `group_code=block_code`;用户不能单独改 block_code |
|
||||||
|
| UI-09 | Parent split 房量差异 | Parent/Child 数量不等不自动显示冲突或人工复核 |
|
||||||
|
| UI-10 | 全 Child Trace | 使用现有 Trace 卡;展示完整 `related_source_event_indices[]`;不压缩到 E1 |
|
||||||
|
| UI-11 | raw evidence | 用户编辑后 raw 原文、附件引用和原 Agent payload 不变 |
|
||||||
|
| UI-12 | 同卡解阻 | 只创建一张卡;`pending → resolved → Preflight`;不创建第二张 normal task |
|
||||||
|
| UI-13 | 未知字段路径 | pointer 无法映射时 fail closed,不能临时创建任意输入控件 |
|
||||||
|
| UI-14 | S10/S99/基础设施错误 | 全部只读,不显示业务编辑控件 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 交付要求
|
||||||
|
|
||||||
|
完成后请报告:
|
||||||
|
|
||||||
|
1. 实际修改的 Adapter/Frontend 文件。
|
||||||
|
2. 新增或更新的字段注册表及控件类型。
|
||||||
|
3. `missing_fields[] → control → field_overrides[]` 的实现位置。
|
||||||
|
4. 日期、房型、Rate Code、Fix Charge、Parent/Trace 关系校验位置。
|
||||||
|
5. 测试命令和测试结果。
|
||||||
|
6. 是否保持公开 JSON、40 路由和 MCP mapping 不变。
|
||||||
|
7. 未实现字段、路径不确定项和需要产品/业务确认的事项。
|
||||||
|
|
||||||
|
如果发现当前字段路径、item schema 或 Rate/Fix Charge 枚举仍未冻结,请保留只读/容错策略并报告,不要在前端自行发明新的公开字段。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 最终边界
|
||||||
|
|
||||||
|
本任务的正确结果是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
信息系统可以正确展示当前业务结果
|
||||||
|
信息系统只允许修改被 manual_review pointer 指定的业务字段
|
||||||
|
用户修改可审计、可校验、可回退到同一业务卡
|
||||||
|
原始 Agent 结果和证据不被覆盖
|
||||||
|
不新增业务路由、不改变 Prompt/Skill/MCP 契约
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user